diff --git a/.agents/skills/create-issue-interaction-ui/SKILL.md b/.agents/skills/create-issue-interaction-ui/SKILL.md index 9cebfacc95b..984a81ad612 100644 --- a/.agents/skills/create-issue-interaction-ui/SKILL.md +++ b/.agents/skills/create-issue-interaction-ui/SKILL.md @@ -163,7 +163,7 @@ Touch: resolved, stale, disabled/submitting, and validation-error states. - `ui/src/fixtures/issueThreadInteractionFixtures.ts` — seed at least one pending and one resolved fixture for the new kind. -- `ui/src/stories/issue-thread-interactions.stories.tsx` — Storybook entries +- `ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx` — Storybook entries for the key states. - `ui/src/pages/IssueDetail.tsx` — extend the per-kind branches the card is rendered from (callback wiring, response submission). diff --git a/.agents/skills/maintain-storybook/SKILL.md b/.agents/skills/maintain-storybook/SKILL.md new file mode 100644 index 00000000000..5735eb5e56b --- /dev/null +++ b/.agents/skills/maintain-storybook/SKILL.md @@ -0,0 +1,78 @@ +--- +name: maintain-storybook +description: Keep Paperclip stories and their downstream catalog aligned with the feature map and production UI. Use to reconcile story drift, classify prototypes, update fixtures, or maintain feature coverage. +--- + +# Maintain Storybook + +Read the [Storybook catalog contract](../../../maps/storybook-map/README.md), +the relevant [feature recipes](../../../maps/feature-map/README.md), and +[DESIGN.md](../../../DESIGN.md). The feature map is an upstream input. This skill +owns stories, their fixtures/support, and `maps/storybook-map/`; it does not edit +the feature or agent-access maps. Report upstream drift to +`maintain-verification-skill` with source evidence. + +## Reconcile the requested scope + +For a feature/change, follow its capability IDs to catalog entries and shared +dependencies. For a full inventory, account for every configured story file, +including historical examples. Start with `node scripts/storybook-catalog.mjs`. +Record the source revision and whether the pass covers source, rendering, +interactions, or visual comparisons. Inventory completeness is not behavioral +coverage. Do not expand a targeted maintenance request into an all-product audit. + +Compare each affected story's rendered subject, props, fixture responses, actions, +and assertions with the production implementation and recipe. Follow imported +wrappers: a production Button inside a locally recreated page does not make that +page a production-component story. A real production page using mocked APIs is +valid isolation; record component provenance and data simulation separately. +Inspect mock methods/paths, payload shapes, errors, identity, gates, and reset +behavior. An API method returning success without the relevant state transition +can hide drift. Do not replace errors with generic successful mocks to pass. + +Prefer mounting the shipped subject and mocking its external boundary. Retain +local layout wrappers and fixture data when they serve that isolation. Replace a +duplicate product implementation only when the current component expresses the +same scenario without losing the original purpose. Production bugs and larger +redesigns are separate findings, not reasons to rewrite expected behavior. + +Classify current examples, proposals, historical explorations, and unresolved +cases explicitly. Use `unreviewed` when evidence does not establish parity. +Retain useful superseded work with its rationale and replacement reference; +do not delete a story merely because it is old, mocked, or duplicated by name. +Record mixed suites honestly and split them only when that improves review. + +## Preserve identity and keep the index useful + +Assign existing stable `area/capability` references, with one primary reference +and optional related references. Foundations and unmapped proposals need explicit +reasons rather than invented capabilities. Cross-cutting stories have one source +and several references. Do not count historical/proposed stories as current +feature coverage. + +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 +map's section and feature names as their sidebar prefix; keep useful scenario +groupings beneath that prefix. Put shared fixture modules in `ui/storybook/support/`. +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 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 +are the demonstrated subject. Do not cap the number of source references. +Update review fingerprints only after inspecting the changed source; do not refresh hashes merely +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 +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. +Do not accept new screenshot baselines as part of a catalog cleanup. + +Report what was corrected, retained, and still needs review, naming exact files +and capabilities. Keep source-only results separate from executed results and +name blocked checks. A full pass must not hide unreviewed stories or claim that +fixture interactions establish production API, permission, or provider behavior. diff --git a/.agents/skills/maintain-storybook/agents/openai.yaml b/.agents/skills/maintain-storybook/agents/openai.yaml new file mode 100644 index 00000000000..79bc58f9192 --- /dev/null +++ b/.agents/skills/maintain-storybook/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Maintain Storybook" + short_description: "Align stories with features and production UI" diff --git a/.agents/skills/review-storybook/SKILL.md b/.agents/skills/review-storybook/SKILL.md new file mode 100644 index 00000000000..cdc1bc07bc6 --- /dev/null +++ b/.agents/skills/review-storybook/SKILL.md @@ -0,0 +1,47 @@ +--- +name: review-storybook +description: Review Paperclip stories for a feature or change using the Storybook catalog, production source, and existing browser tests. Report component, mock, interaction, and visual evidence separately. +--- + +# Review Storybook + +Read the [catalog contract](../../../maps/storybook-map/README.md) and find the +requested capability in its generated index. Check the relevant feature recipe, +production subject, and story before driving it. Catalog status is prior review +context, not proof of the current revision. This skill consumes the maps/catalog; +corrections belong to `maintain-storybook` or `maintain-verification-skill`. + +Choose discriminating states for the requested change: the normal action and +result, relevant failure/retry, gate/permission presentation, and mobile/theme +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. + +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 +links. Do not trust an unrelated server on the default port. Keep the process +handle and stop only the server this review started, retaining useful evidence. + +Inspect the real rendered subject, including errors and browser console output. +For interactive stories, wait for their `play` assertions to complete and inspect +the observable result. A screenshot alone does not prove `play` passed. Verify +fixture isolation by revisiting/resetting relevant stories when they mutate +fetch handlers, storage, query caches, time, or module state. Avoid real provider +actions; explicitly document any live dependency required by a story. + +The existing visual suite is +`tests/storybook-visual/storybook-visual.spec.ts`. It uses external baselines and +a pinned Linux environment. Inspect `baseline-manifest.json` before claiming +visual regression proof. An unpublished manifest blocks comparisons; a macOS +capture is useful inspection, not the Linux baseline. Do not update/publish +baselines just to make a review pass. Use existing focused browser suites where +they cover the requested scenario, and the production verification skill when +the question requires backend persistence, real authorization, or provider proof. + +Report each selected case as passed, failed, blocked, or not run, naming revision, +story ID, environment, production versus prototype subject, simulated boundaries, +action/assertion and observed result. Separate render, interaction, visual +comparison, and production acceptance evidence. Report proven drift with exact +source references and a proposed correction; do not lower expectations, remove +failing cases, or silently bless historical behavior as current. diff --git a/.agents/skills/review-storybook/agents/openai.yaml b/.agents/skills/review-storybook/agents/openai.yaml new file mode 100644 index 00000000000..16a92f0250b --- /dev/null +++ b/.agents/skills/review-storybook/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Review Storybook" + short_description: "Review story rendering, interactions, and drift" diff --git a/.dockerignore b/.dockerignore index 8ae5f323da0..f0436b4e6e7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -56,6 +56,7 @@ packages/paperclip-runner/test-fixtures/pi-acp/** !packages/paperclip-runner/test-fixtures/pi-acp/profile-v22-identity.json !packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json !packages/paperclip-runner/test-fixtures/pi-acp/profile-v24-identity.json +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json !packages/paperclip-runner/docs/capability-contract.md # check:runner-workflow-traceability access()es every regression test the # stress-traceability spec names — those are src/**/*.test.ts files, so diff --git a/.github/scripts/tests/ci-routing.test.mjs b/.github/scripts/tests/ci-routing.test.mjs new file mode 100644 index 00000000000..a9703feb747 --- /dev/null +++ b/.github/scripts/tests/ci-routing.test.mjs @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import vm from "node:vm"; + +const workflow = readFileSync(new URL("../../workflows/ci-routing.yml", import.meta.url), "utf8"); +const jobs = workflow.split(/^jobs:\n/m)[1]; + +test("routing uses reserved capacity and runs no PR code or actions", () => { + assert.deepEqual([...jobs.matchAll(/^ {2}([a-z_]+):$/gm)].map((match) => match[1]), ["gate"]); + const placement = jobs.match(/^ {4}runs-on: (.+)$/m)?.[1]; + assert.ok(placement, "routing placement must be defined"); + assert.doesNotMatch(placement, /fromJSON/); + assert.ok(placement.includes("vars.AWS_CI_TRUSTED_USER_IDS == vars.AWS_CI_ROUTING_USER_IDS_SOURCE")); + assert.ok(placement.includes("github.event.pull_request.user.id")); + assert.ok(placement.includes("github.event.sender.id")); + assert.ok(placement.endsWith("&& 'runs-on/fleet=paperclip-ci-routing-x64/env=public-ci' || 'ubuntu-latest' }}")); + assert.doesNotMatch(jobs, /uses:|actions\/checkout|secrets\./); + assert.doesNotMatch(workflow, /^ {2}(?:actions|contents|pull-requests): write$/m); +}); + +test("routing exports both placement and stack scope to callers", () => { + assert.match(workflow, /^ {6}runner:\n {8}value: \$\{\{ jobs\.gate\.outputs\.runner \}\}$/m); + assert.match(workflow, /^ {6}full_ci:\n {8}value: \$\{\{ jobs\.gate\.outputs\.full_ci \}\}$/m); + assert.match(jobs, /^ {6}runner: \$\{\{ steps\.route\.outputs\.runner \}\}$/m); + assert.match(jobs, /^ {6}full_ci: \$\{\{ steps\.scope\.outputs\.full_ci \}\}$/m); +}); + +const githubStub = "#!/usr/bin/env bash\nset -Eeuo pipefail\n\nendpoint=\"${*: -1}\"\ncase \"$endpoint\" in\n */pulls/*)\n jq -cn \\\n --argjson author_id \"${TEST_PR_AUTHOR_ID:-34892728}\" \\\n --argjson repository_id \"${TEST_REPOSITORY_ID:-1170821064}\" \\\n --arg base_ref \"${TEST_BASE_REF:-master}\" \\\n --arg base_sha \"${TEST_PR_BASE_SHA:-$EVENT_BASE_SHA}\" \\\n --arg head_sha \"${TEST_PR_HEAD_SHA:-1111111111111111111111111111111111111111}\" \\\n --arg merge_sha \"${TEST_PR_MERGE_SHA:-4444444444444444444444444444444444444444}\" '{\n state: \"open\",\n user: {id: $author_id},\n base: {repo: {id: $repository_id}, ref: $base_ref, sha: $base_sha},\n head: {sha: $head_sha},\n merge_commit_sha: $merge_sha\n }'\n ;;\n */git/ref/heads/*)\n jq -cn \\\n --arg sha \"${TEST_LIVE_BASE_REF_SHA:-$EVENT_BASE_SHA}\" '{object: {sha: $sha}}'\n ;;\n */compare/*)\n jq -cn \\\n --arg merge_base_sha \"${TEST_BASE_COMPARISON_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\" \\\n --arg status \"${TEST_BASE_COMPARISON_STATUS:-ahead}\" '{\n merge_base_commit: {sha: $merge_base_sha},\n status: $status\n }'\n ;;\n */git/commits/*)\n requested_sha=\"${endpoint##*/}\"\n live_merge_sha=\"${TEST_PR_MERGE_SHA:-4444444444444444444444444444444444444444}\"\n synthetic_base_merge_sha=\"${TEST_SYNTHETIC_BASE_MERGE_SHA:-}\"\n if [[ \"$requested_sha\" == \"$EVENT_MERGE_SHA\" ]]; then\n tree_sha=\"${TEST_EVENT_MERGE_TREE_SHA:-5555555555555555555555555555555555555555}\"\n base_sha=\"${TEST_EVENT_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\"\n head_sha=\"${TEST_EVENT_MERGE_HEAD_SHA:-$EVENT_HEAD_SHA}\"\n elif [[ \"$requested_sha\" == \"$live_merge_sha\" ]]; then\n tree_sha=\"${TEST_LIVE_MERGE_TREE_SHA:-5555555555555555555555555555555555555555}\"\n base_sha=\"${TEST_LIVE_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\"\n head_sha=\"${TEST_LIVE_MERGE_HEAD_SHA:-$EVENT_HEAD_SHA}\"\n elif [[ -n \"$synthetic_base_merge_sha\" && \"$requested_sha\" == \"$synthetic_base_merge_sha\" ]]; then\n tree_sha=\"${TEST_SYNTHETIC_BASE_MERGE_TREE_SHA:-8888888888888888888888888888888888888888}\"\n base_sha=\"${TEST_SYNTHETIC_MASTER_PARENT_SHA:-9999999999999999999999999999999999999999}\"\n head_sha=\"${TEST_SYNTHETIC_BASE_REF_PARENT_SHA:-${TEST_LIVE_BASE_REF_SHA:-$EVENT_BASE_SHA}}\"\n else\n echo \"unexpected merge commit: $requested_sha\" >&2\n exit 1\n fi\n jq -cn \\\n --arg sha \"$requested_sha\" \\\n --arg tree_sha \"$tree_sha\" \\\n --arg base_sha \"$base_sha\" \\\n --arg head_sha \"$head_sha\" '{\n sha: $sha,\n tree: {sha: $tree_sha},\n parents: [{sha: $base_sha}, {sha: $head_sha}]\n }'\n ;;\n */actions/runs/*)\n jq -cn \\\n --argjson actor_id \"${TEST_TRIGGERING_ACTOR_ID:-34892728}\" \\\n --argjson repository_id \"${TEST_REPOSITORY_ID:-1170821064}\" \\\n --arg event_name \"${TEST_EVENT_NAME:-pull_request}\" '{\n triggering_actor: {id: $actor_id},\n repository: {id: $repository_id},\n event: $event_name\n }'\n ;;\n *)\n echo \"unexpected gh endpoint: $endpoint\" >&2\n exit 1\n ;;\nesac"; + +function stepScript(name) { + const start = workflow.indexOf(` - name: ${name}\n`); + assert.ok(start >= 0, `missing step: ${name}`); + const body = workflow.slice(start); + const runStart = body.indexOf(" run: |\n"); + assert.ok(runStart >= 0, `missing script: ${name}`); + const script = body.slice(runStart + " run: |\n".length).split("\n - name:")[0]; + return script.split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n"); +} + +function executeStep(name, overrides = {}) { + const directory = mkdtempSync(path.join(tmpdir(), "paperclip-routing-test-")); + const output = path.join(directory, "output"); + try { + const gh = path.join(directory, "gh"); + writeFileSync(gh, githubStub); + chmodSync(gh, 0o700); + const result = spawnSync("bash", ["-c", stepScript(name)], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + GITHUB_OUTPUT: output, + AWS_CI_ENABLED: "true", + TRUSTED_USER_IDS: "[34892728]", + EVENT_NAME: "pull_request", + EVENT_REPOSITORY: "paperclipai/paperclip", + EVENT_REPOSITORY_ID: "1170821064", + EVENT_ACTION: "synchronize", + EVENT_PR_NUMBER: "12350", + EVENT_PR_AUTHOR_ID: "34892728", + EVENT_SENDER_ID: "34892728", + EVENT_BASE_REPOSITORY_ID: "1170821064", + EVENT_BASE_REF: "master", + EVENT_BASE_SHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + EVENT_HEAD_SHA: "1111111111111111111111111111111111111111", + EVENT_MERGE_SHA: "2222222222222222222222222222222222222222", + RUN_ID: "123456789", + ...overrides, + }, + }); + assert.equal(result.status, 0, result.stderr); + const lines = readFileSync(output, "utf8").trim().split("\n"); + assert.equal(lines.length, 1, "each step must emit exactly one decision"); + return Object.fromEntries(lines.map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + })); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +const awsRunner = "runs-on/fleet=paperclip-public-pr-x64/env=public-ci"; +const stackedBase = { + EVENT_BASE_REF: "trusted-stack-base", + TEST_BASE_REF: "trusted-stack-base", + TEST_PR_BASE_SHA: "7777777777777777777777777777777777777777", + TEST_LIVE_BASE_REF_SHA: "7777777777777777777777777777777777777777", + TEST_SYNTHETIC_BASE_MERGE_SHA: "8888888888888888888888888888888888888888", + TEST_EVENT_MERGE_BASE_SHA: "8888888888888888888888888888888888888888", + TEST_LIVE_MERGE_BASE_SHA: "8888888888888888888888888888888888888888", +}; + +const routeCases = [ + ["allowlisted PR", {}, awsRunner], + ["validated stacked base", stackedBase, awsRunner], + ["lagging PR API base snapshot", { TEST_PR_BASE_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, awsRunner], + ["disabled AWS", { AWS_CI_ENABLED: "false" }], + ["missing AWS variables", { AWS_CI_ENABLED: "", TRUSTED_USER_IDS: "" }], + ["unlisted author", { EVENT_PR_AUTHOR_ID: "99999999" }], + ["unlisted sender", { EVENT_SENDER_ID: "99999999" }], + ["unlisted rerun actor", { TEST_TRIGGERING_ACTOR_ID: "99999999" }], + ["missing base", { EVENT_BASE_REF: "" }], + ["wrong base repository", { EVENT_BASE_REPOSITORY_ID: "999999999" }], + ["replaced base ancestry", { TEST_BASE_COMPARISON_MERGE_BASE_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }], + ["stale head", { TEST_PR_HEAD_SHA: "3333333333333333333333333333333333333333" }], + ["changed merge head", { TEST_LIVE_MERGE_HEAD_SHA: "3333333333333333333333333333333333333333" }], + ["stacked base with wrong parent", { ...stackedBase, TEST_SYNTHETIC_BASE_REF_PARENT_SHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }], + ["changed merge tree", { TEST_LIVE_MERGE_TREE_SHA: "6666666666666666666666666666666666666666" }], + ["wrong repository", { EVENT_REPOSITORY_ID: "999999999" }], + ["malformed allowlist", { TRUSTED_USER_IDS: '["34892728"]' }], + ["manual dispatch", { EVENT_NAME: "workflow_dispatch" }], + ["unsupported PR action", { EVENT_ACTION: "closed" }], + ["malformed commit SHA", { EVENT_HEAD_SHA: "invalid" }], +]; + +for (const [name, env, expected = "ubuntu-latest"] of routeCases) { + test(`runner decision: ${name}`, () => { + assert.equal(executeStep("Validate PR identity and select runner", env).runner, expected); + }); +} + +const scopeCases = [ + ["ordinary PR", null, "master", "true"], + ["top stack PR", { position: 11, size: 11, base: { ref: "master" } }, "stack-10", "true"], + ["lowest unmerged PR", { position: 1, size: 11, base: { ref: "master" } }, "master", "true"], + ["middle stack PR", { position: 6, size: 11, base: { ref: "master" } }, "stack-5", "false"], + ["malformed position", { position: "invalid", size: 11, base: { ref: "master" } }, "stack-5", "true"], + ["out-of-range position", { position: 12, size: 11, base: { ref: "master" } }, "stack-5", "true"], + ["missing stack base", { position: 6, size: 11 }, "stack-5", "true"], + ["empty metadata", {}, "master", "true"], +]; + +for (const [name, stack, base, expected] of scopeCases) { + test(`scope decision: ${name}`, () => { + assert.equal(executeStep("Select stacked PR CI scope", { + STACK_JSON: JSON.stringify(stack), PR_BASE_REF: base, + }).full_ci, expected); + }); +} + + +function routingPlacement(vars = {}, github = {}) { + const source = "[34892728]"; + const expression = jobs.match(/^ {4}runs-on: \$\{\{ (.+) \}\}$/m)?.[1]; + assert.ok(expression, "routing expression is missing"); + return vm.runInNewContext(expression, { + vars: { + AWS_CI_ENABLED: "true", + AWS_CI_TRUSTED_USER_IDS: source, + AWS_CI_ROUTING_USER_IDS_SOURCE: source, + AWS_CI_ROUTING_USER_IDS: "|34892728|", + ...vars, + }, + github: { + repository: "paperclipai/paperclip", + repository_id: "1170821064", + event_name: "pull_request", + ref: "refs/heads/master", + actor_id: "34892728", + event: { pull_request: { user: { id: 34892728 } }, sender: { id: 34892728 } }, + ...github, + }, + contains: (search, item) => String(search).toLowerCase().includes(String(item).toLowerCase()), + format: (template, ...args) => template.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])), + }, { timeout: 1000 }); +} + +const placementCases = [ + ["approved PR", {}, {}, "runs-on/fleet=paperclip-ci-routing-x64/env=public-ci"], + ["unapproved author", {}, { event: { pull_request: { user: { id: 999 } }, sender: { id: 34892728 } } }], + ["unapproved sender", {}, { event: { pull_request: { user: { id: 34892728 } }, sender: { id: 999 } } }], + ["partial ID cannot match", {}, { event: { pull_request: { user: { id: 4892728 } }, sender: { id: 34892728 } } }], + ["invalid JSON keeps hosted fallback", { AWS_CI_TRUSTED_USER_IDS: "[34892728,]" }, {}], + ["stale snapshot", { AWS_CI_TRUSTED_USER_IDS: "[34892728,999]" }, {}], + ["missing snapshot", { AWS_CI_ROUTING_USER_IDS_SOURCE: "" }, {}], + ["missing admission members", { AWS_CI_ROUTING_USER_IDS: "" }, {}], + ["disabled routing", { AWS_CI_ENABLED: "false" }, {}], + ["different repository", {}, { repository: "other/repo" }], + ["different repository ID", {}, { repository_id: "999" }], + ["approved manual master canary", {}, { event_name: "workflow_dispatch" }, "runs-on/fleet=paperclip-ci-routing-x64/env=public-ci"], + ["unapproved manual actor", {}, { event_name: "workflow_dispatch", actor_id: "999" }], + ["manual branch canary", {}, { event_name: "workflow_dispatch", ref: "refs/heads/other" }], +]; + +for (const [name, vars, github, expected = "ubuntu-latest"] of placementCases) { + test(`placement before allocation: ${name}`, () => { + assert.equal(routingPlacement(vars, github), expected); + }); +} diff --git a/.github/workflows/ci-routing.yml b/.github/workflows/ci-routing.yml new file mode 100644 index 00000000000..5ad3e1e11e3 --- /dev/null +++ b/.github/workflows/ci-routing.yml @@ -0,0 +1,252 @@ +name: CI routing + +on: + workflow_dispatch: + workflow_call: + outputs: + runner: + value: ${{ jobs.gate.outputs.runner }} + full_ci: + value: ${{ jobs.gate.outputs.full_ci }} + +permissions: + actions: read + contents: read + pull-requests: read + +jobs: + gate: + name: Select trusted runner + runs-on: ${{ vars.AWS_CI_ENABLED == 'true' && vars.AWS_CI_ROUTING_USER_IDS_SOURCE != '' && vars.AWS_CI_TRUSTED_USER_IDS == vars.AWS_CI_ROUTING_USER_IDS_SOURCE && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && ((github.event_name == 'pull_request' && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.event.pull_request.user.id)) && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.event.sender.id))) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master' && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.actor_id)))) && 'runs-on/fleet=paperclip-ci-routing-x64/env=public-ci' || 'ubuntu-latest' }} + timeout-minutes: 5 + outputs: + runner: ${{ steps.route.outputs.runner }} + full_ci: ${{ steps.scope.outputs.full_ci }} + + steps: + - name: Validate PR identity and select runner + id: route + shell: bash + env: + GH_TOKEN: ${{ github.token }} + AWS_CI_ENABLED: ${{ vars.AWS_CI_ENABLED }} + TRUSTED_USER_IDS: ${{ vars.AWS_CI_TRUSTED_USER_IDS }} + EVENT_NAME: ${{ github.event_name }} + EVENT_REPOSITORY: ${{ github.repository }} + EVENT_REPOSITORY_ID: ${{ github.repository_id }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }} + EVENT_SENDER_ID: ${{ github.event.sender.id }} + EVENT_BASE_REPOSITORY_ID: ${{ github.event.pull_request.base.repo.id }} + EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_MERGE_SHA: ${{ github.sha }} + RUN_ID: ${{ github.run_id }} + run: | + set -u + + github_runner='ubuntu-latest' + aws_runner='runs-on/fleet=paperclip-public-pr-x64/env=public-ci' + + fail_closed() { + echo "runner=$github_runner" >> "$GITHUB_OUTPUT" + echo "::notice title=AWS CI routing::Using GitHub-hosted runner: $1" + exit 0 + } + + is_positive_integer() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] + } + + is_commit_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ ]] + } + + is_allowed() { + local user_id="$1" + jq -e --argjson user_id "$user_id" 'index($user_id) != null' \ + <<< "$TRUSTED_USER_IDS" >/dev/null + } + + [[ "$AWS_CI_ENABLED" == 'true' ]] || fail_closed 'AWS_CI_ENABLED is not true' + [[ "$EVENT_NAME" == 'pull_request' ]] || fail_closed 'event is not pull_request' + [[ "$EVENT_REPOSITORY" == 'paperclipai/paperclip' ]] || fail_closed 'unexpected repository' + [[ "$EVENT_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected repository ID' + [[ "$EVENT_BASE_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected base repository ID' + [[ -n "$EVENT_BASE_REF" ]] || fail_closed 'missing base branch' + [[ "$EVENT_ACTION" =~ ^(opened|reopened|synchronize)$ ]] || fail_closed 'unsupported pull_request action' + + jq -e ' + type == "array" and + length > 0 and + all(.[]; type == "number" and . > 0 and floor == .) + ' <<< "$TRUSTED_USER_IDS" >/dev/null 2>&1 || fail_closed 'trusted user ID list is malformed' + + for user_id in "$EVENT_PR_AUTHOR_ID" "$EVENT_SENDER_ID"; do + is_positive_integer "$user_id" || fail_closed 'event contains a malformed user ID' + is_allowed "$user_id" || fail_closed "GitHub user ID $user_id is not allowlisted" + done + + for commit_sha in "$EVENT_BASE_SHA" "$EVENT_HEAD_SHA" "$EVENT_MERGE_SHA"; do + is_commit_sha "$commit_sha" || fail_closed 'event contains a malformed commit SHA' + done + + pr_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/pulls/$EVENT_PR_NUMBER" 2>/dev/null)" \ + || fail_closed 'could not refresh pull request state' + + jq -e \ + --argjson repository_id 1170821064 \ + --argjson author_id "$EVENT_PR_AUTHOR_ID" \ + --arg base_ref "$EVENT_BASE_REF" \ + --arg head_sha "$EVENT_HEAD_SHA" \ + ' + .state == "open" and + .user.id == $author_id and + .base.repo.id == $repository_id and + .base.ref == $base_ref and + .head.sha == $head_sha + ' <<< "$pr_json" >/dev/null 2>&1 \ + || fail_closed 'current pull request state does not match the triggering event' + + live_merge_sha="$(jq -r '.merge_commit_sha // empty' <<< "$pr_json")" + is_commit_sha "$live_merge_sha" || fail_closed 'current pull request has no valid merge SHA' + + base_ref_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/git/ref/heads/$EVENT_BASE_REF" 2>/dev/null)" \ + || fail_closed 'could not inspect the current base branch' + live_base_ref_sha="$(jq -r '.object.sha // empty' <<< "$base_ref_json")" + is_commit_sha "$live_base_ref_sha" || fail_closed 'current base branch has no valid commit SHA' + + base_comparison="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/compare/$EVENT_BASE_SHA...$live_base_ref_sha" 2>/dev/null)" \ + || fail_closed 'could not compare the triggering and current base branches' + jq -e \ + --arg event_base_sha "$EVENT_BASE_SHA" ' + .merge_base_commit.sha == $event_base_sha and + (.status == "ahead" or .status == "identical") + ' <<< "$base_comparison" >/dev/null 2>&1 \ + || fail_closed 'current base branch does not descend from the triggering base snapshot' + + event_merge_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/git/commits/$EVENT_MERGE_SHA" 2>/dev/null)" \ + || fail_closed 'could not inspect the event merge commit' + + live_merge_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/git/commits/$live_merge_sha" 2>/dev/null)" \ + || fail_closed 'could not inspect the current merge commit' + + validate_merge_commit() { + local merge_json="$1" + local expected_sha="$2" + jq -e \ + --arg expected_sha "$expected_sha" \ + --arg head_sha "$EVENT_HEAD_SHA" ' + .sha == $expected_sha and + (.parents | length) == 2 and + .parents[1].sha == $head_sha and + (.parents[0].sha | test("^[0-9a-f]{40}$")) and + (.tree.sha | test("^[0-9a-f]{40}$")) + ' <<< "$merge_json" >/dev/null 2>&1 + } + + validate_merge_commit "$event_merge_json" "$EVENT_MERGE_SHA" \ + || fail_closed 'event merge commit does not match the current base and head' + validate_merge_commit "$live_merge_json" "$live_merge_sha" \ + || fail_closed 'current merge commit does not match the triggering base and head' + + event_merge_parent="$(jq -r '.parents[0].sha' <<< "$event_merge_json")" + live_merge_parent="$(jq -r '.parents[0].sha' <<< "$live_merge_json")" + [[ "$event_merge_parent" == "$live_merge_parent" ]] \ + || fail_closed 'current merge commit uses a different base merge parent' + + if [[ "$event_merge_parent" != "$live_base_ref_sha" ]]; then + base_merge_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/git/commits/$event_merge_parent" 2>/dev/null)" \ + || fail_closed 'could not inspect the stacked base merge commit' + jq -e \ + --arg expected_sha "$event_merge_parent" \ + --arg live_base_ref_sha "$live_base_ref_sha" ' + .sha == $expected_sha and + (.parents | length) == 2 and + any(.parents[]; .sha == $live_base_ref_sha) and + (.tree.sha | test("^[0-9a-f]{40}$")) + ' <<< "$base_merge_json" >/dev/null 2>&1 \ + || fail_closed 'stacked base merge commit does not contain the current base branch' + fi + + event_merge_tree="$(jq -r '.tree.sha' <<< "$event_merge_json")" + live_merge_tree="$(jq -r '.tree.sha' <<< "$live_merge_json")" + [[ "$event_merge_tree" == "$live_merge_tree" ]] \ + || fail_closed 'current merge tree differs from the triggering merge tree' + + run_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/actions/runs/$RUN_ID" 2>/dev/null)" \ + || fail_closed 'could not refresh workflow run state' + + triggering_actor_id="$(jq -r '.triggering_actor.id // empty' <<< "$run_json")" + is_positive_integer "$triggering_actor_id" || fail_closed 'workflow run has no valid triggering actor ID' + is_allowed "$triggering_actor_id" || fail_closed "triggering GitHub user ID $triggering_actor_id is not allowlisted" + + jq -e \ + --argjson repository_id 1170821064 \ + --arg event_name "$EVENT_NAME" ' + .repository.id == $repository_id and + .event == $event_name + ' <<< "$run_json" >/dev/null 2>&1 \ + || fail_closed 'current workflow run does not match the expected repository event' + + echo "runner=$aws_runner" >> "$GITHUB_OUTPUT" + echo '::notice title=AWS CI routing::Using an ephemeral RunsOn Fleet runner' + + - name: Select stacked PR CI scope + id: scope + shell: bash + env: + STACK_JSON: ${{ toJSON(github.event.pull_request.stack) }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + + full_ci='true' + reason='ordinary pull request' + + if jq -e 'type == "object"' <<< "$STACK_JSON" >/dev/null 2>&1; then + stack_position="$(jq -r '.position // empty' <<< "$STACK_JSON")" + stack_size="$(jq -r '.size // empty' <<< "$STACK_JSON")" + stack_base_ref="$(jq -r '.base.ref // empty' <<< "$STACK_JSON")" + + if [[ ! "$stack_position" =~ ^[1-9][0-9]*$ ]] || + [[ ! "$stack_size" =~ ^[1-9][0-9]*$ ]] || + (( stack_position > stack_size )) || + [[ -z "$stack_base_ref" ]]; then + reason='malformed stack metadata; defaulting to full CI' + elif (( stack_position == stack_size )); then + reason='top pull request in stack' + elif [[ "$stack_base_ref" == "$PR_BASE_REF" ]]; then + reason='lowest unmerged pull request in stack' + else + full_ci='false' + reason='middle pull request in stack' + fi + fi + + echo "full_ci=$full_ci" >> "$GITHUB_OUTPUT" + echo "::notice title=Stacked PR CI scope::$reason; full_ci=$full_ci" + diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml index 7bcc6fe5b7b..ae00c65df35 100644 --- a/.github/workflows/docker-runner-check.yml +++ b/.github/workflows/docker-runner-check.yml @@ -177,8 +177,6 @@ jobs: run: | set -uo pipefail pnpm install --frozen-lockfile --ignore-scripts - # Historical evaluation fixtures read these exact public trees. - # Fetch only their objects; keep the selected checkout unchanged. git fetch --no-tags --depth=1 origin 2a8a99e4a5f69aa803b3f10b982f583e75a87042 16ef1a5744b23bb043a8b286e6288921da1de98f status=0 for check in 'pnpm -r typecheck' 'pnpm test:run' 'pnpm check:token-gates' 'pnpm test:e2e:runner:typecheck' 'pnpm test:e2e:runner:unit' 'pnpm build' 'if [ -f scripts/verify-grok-npm-install.mjs ]; then node scripts/verify-grok-npm-install.mjs; fi'; do diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index ec62707c417..65f72fa8c56 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -17,241 +17,7 @@ concurrency: jobs: gate: - name: Select trusted runner - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - runner: ${{ steps.route.outputs.runner }} - full_ci: ${{ steps.scope.outputs.full_ci }} - - steps: - - name: Validate PR identity and select runner - id: route - shell: bash - env: - GH_TOKEN: ${{ github.token }} - AWS_CI_ENABLED: ${{ vars.AWS_CI_ENABLED }} - TRUSTED_USER_IDS: ${{ vars.AWS_CI_TRUSTED_USER_IDS }} - EVENT_NAME: ${{ github.event_name }} - EVENT_REPOSITORY: ${{ github.repository }} - EVENT_REPOSITORY_ID: ${{ github.repository_id }} - EVENT_ACTION: ${{ github.event.action }} - EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} - EVENT_PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }} - EVENT_SENDER_ID: ${{ github.event.sender.id }} - EVENT_BASE_REPOSITORY_ID: ${{ github.event.pull_request.base.repo.id }} - EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} - EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} - EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - EVENT_MERGE_SHA: ${{ github.sha }} - RUN_ID: ${{ github.run_id }} - run: | - set -u - - github_runner='ubuntu-latest' - aws_runner='runs-on/fleet=paperclip-public-pr-x64/env=public-ci' - - fail_closed() { - echo "runner=$github_runner" >> "$GITHUB_OUTPUT" - echo "::notice title=AWS CI routing::Using GitHub-hosted runner: $1" - exit 0 - } - - is_positive_integer() { - [[ "$1" =~ ^[1-9][0-9]*$ ]] - } - - is_commit_sha() { - [[ "$1" =~ ^[0-9a-f]{40}$ ]] - } - - is_allowed() { - local user_id="$1" - jq -e --argjson user_id "$user_id" 'index($user_id) != null' \ - <<< "$TRUSTED_USER_IDS" >/dev/null - } - - [[ "$AWS_CI_ENABLED" == 'true' ]] || fail_closed 'AWS_CI_ENABLED is not true' - # Reusable workflows retain the caller's github context and event - # payload, so a pull_request caller must still report pull_request. - [[ "$EVENT_NAME" == 'pull_request' ]] || fail_closed 'event is not pull_request' - [[ "$EVENT_REPOSITORY" == 'paperclipai/paperclip' ]] || fail_closed 'unexpected repository' - [[ "$EVENT_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected repository ID' - [[ "$EVENT_BASE_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected base repository ID' - [[ -n "$EVENT_BASE_REF" ]] || fail_closed 'missing base branch' - [[ "$EVENT_ACTION" =~ ^(opened|reopened|synchronize)$ ]] || fail_closed 'unsupported pull_request action' - - jq -e ' - type == "array" and - length > 0 and - all(.[]; type == "number" and . > 0 and floor == .) - ' <<< "$TRUSTED_USER_IDS" >/dev/null 2>&1 || fail_closed 'trusted user ID list is malformed' - - for user_id in "$EVENT_PR_AUTHOR_ID" "$EVENT_SENDER_ID"; do - is_positive_integer "$user_id" || fail_closed 'event contains a malformed user ID' - is_allowed "$user_id" || fail_closed "GitHub user ID $user_id is not allowlisted" - done - - for commit_sha in "$EVENT_BASE_SHA" "$EVENT_HEAD_SHA" "$EVENT_MERGE_SHA"; do - is_commit_sha "$commit_sha" || fail_closed 'event contains a malformed commit SHA' - done - - pr_json="$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - "/repos/paperclipai/paperclip/pulls/$EVENT_PR_NUMBER" 2>/dev/null)" \ - || fail_closed 'could not refresh pull request state' - - jq -e \ - --argjson repository_id 1170821064 \ - --argjson author_id "$EVENT_PR_AUTHOR_ID" \ - --arg base_ref "$EVENT_BASE_REF" \ - --arg head_sha "$EVENT_HEAD_SHA" \ - ' - .state == "open" and - .user.id == $author_id and - .base.repo.id == $repository_id and - .base.ref == $base_ref and - .head.sha == $head_sha - ' <<< "$pr_json" >/dev/null 2>&1 \ - || fail_closed 'current pull request state does not match the triggering event' - - live_merge_sha="$(jq -r '.merge_commit_sha // empty' <<< "$pr_json")" - is_commit_sha "$live_merge_sha" || fail_closed 'current pull request has no valid merge SHA' - - base_ref_json="$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - "/repos/paperclipai/paperclip/git/ref/heads/$EVENT_BASE_REF" 2>/dev/null)" \ - || fail_closed 'could not inspect the current base branch' - live_base_ref_sha="$(jq -r '.object.sha // empty' <<< "$base_ref_json")" - is_commit_sha "$live_base_ref_sha" || fail_closed 'current base branch has no valid commit SHA' - - base_comparison="$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - "/repos/paperclipai/paperclip/compare/$EVENT_BASE_SHA...$live_base_ref_sha" 2>/dev/null)" \ - || fail_closed 'could not compare the triggering and current base branches' - jq -e \ - --arg event_base_sha "$EVENT_BASE_SHA" ' - .merge_base_commit.sha == $event_base_sha and - (.status == "ahead" or .status == "identical") - ' <<< "$base_comparison" >/dev/null 2>&1 \ - || fail_closed 'current base branch does not descend from the triggering base snapshot' - - event_merge_json="$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - "/repos/paperclipai/paperclip/git/commits/$EVENT_MERGE_SHA" 2>/dev/null)" \ - || fail_closed 'could not inspect the event merge commit' - - live_merge_json="$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - "/repos/paperclipai/paperclip/git/commits/$live_merge_sha" 2>/dev/null)" \ - || fail_closed 'could not inspect the current merge commit' - - validate_merge_commit() { - local merge_json="$1" - local expected_sha="$2" - jq -e \ - --arg expected_sha "$expected_sha" \ - --arg head_sha "$EVENT_HEAD_SHA" ' - .sha == $expected_sha and - (.parents | length) == 2 and - .parents[1].sha == $head_sha and - (.parents[0].sha | test("^[0-9a-f]{40}$")) and - (.tree.sha | test("^[0-9a-f]{40}$")) - ' <<< "$merge_json" >/dev/null 2>&1 - } - - validate_merge_commit "$event_merge_json" "$EVENT_MERGE_SHA" \ - || fail_closed 'event merge commit does not match the current base and head' - validate_merge_commit "$live_merge_json" "$live_merge_sha" \ - || fail_closed 'current merge commit does not match the triggering base and head' - - event_merge_parent="$(jq -r '.parents[0].sha' <<< "$event_merge_json")" - live_merge_parent="$(jq -r '.parents[0].sha' <<< "$live_merge_json")" - [[ "$event_merge_parent" == "$live_merge_parent" ]] \ - || fail_closed 'current merge commit uses a different base merge parent' - - if [[ "$event_merge_parent" != "$live_base_ref_sha" ]]; then - base_merge_json="$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - "/repos/paperclipai/paperclip/git/commits/$event_merge_parent" 2>/dev/null)" \ - || fail_closed 'could not inspect the stacked base merge commit' - jq -e \ - --arg expected_sha "$event_merge_parent" \ - --arg live_base_ref_sha "$live_base_ref_sha" ' - .sha == $expected_sha and - (.parents | length) == 2 and - any(.parents[]; .sha == $live_base_ref_sha) and - (.tree.sha | test("^[0-9a-f]{40}$")) - ' <<< "$base_merge_json" >/dev/null 2>&1 \ - || fail_closed 'stacked base merge commit does not contain the current base branch' - fi - - event_merge_tree="$(jq -r '.tree.sha' <<< "$event_merge_json")" - live_merge_tree="$(jq -r '.tree.sha' <<< "$live_merge_json")" - [[ "$event_merge_tree" == "$live_merge_tree" ]] \ - || fail_closed 'current merge tree differs from the triggering merge tree' - - run_json="$(gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - "/repos/paperclipai/paperclip/actions/runs/$RUN_ID" 2>/dev/null)" \ - || fail_closed 'could not refresh workflow run state' - - triggering_actor_id="$(jq -r '.triggering_actor.id // empty' <<< "$run_json")" - is_positive_integer "$triggering_actor_id" || fail_closed 'workflow run has no valid triggering actor ID' - is_allowed "$triggering_actor_id" || fail_closed "triggering GitHub user ID $triggering_actor_id is not allowlisted" - - jq -e \ - --argjson repository_id 1170821064 \ - --arg event_name "$EVENT_NAME" ' - .repository.id == $repository_id and - .event == $event_name - ' <<< "$run_json" >/dev/null 2>&1 \ - || fail_closed 'current workflow run does not match the expected repository event' - - echo "runner=$aws_runner" >> "$GITHUB_OUTPUT" - echo '::notice title=AWS CI routing::Using an ephemeral RunsOn Fleet runner' - - - name: Select stacked PR CI scope - id: scope - shell: bash - env: - STACK_JSON: ${{ toJSON(github.event.pull_request.stack) }} - PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - run: | - set -euo pipefail - - full_ci='true' - reason='ordinary pull request' - - if jq -e 'type == "object"' <<< "$STACK_JSON" >/dev/null 2>&1; then - stack_position="$(jq -r '.position // empty' <<< "$STACK_JSON")" - stack_size="$(jq -r '.size // empty' <<< "$STACK_JSON")" - stack_base_ref="$(jq -r '.base.ref // empty' <<< "$STACK_JSON")" - - if [[ ! "$stack_position" =~ ^[1-9][0-9]*$ ]] || - [[ ! "$stack_size" =~ ^[1-9][0-9]*$ ]] || - (( stack_position > stack_size )) || - [[ -z "$stack_base_ref" ]]; then - reason='malformed stack metadata; defaulting to full CI' - elif (( stack_position == stack_size )); then - reason='top pull request in stack' - elif [[ "$stack_base_ref" == "$PR_BASE_REF" ]]; then - reason='lowest unmerged pull request in stack' - else - full_ci='false' - reason='middle pull request in stack' - fi - fi - - echo "full_ci=$full_ci" >> "$GITHUB_OUTPUT" - echo "::notice title=Stacked PR CI scope::$reason; full_ci=$full_ci" + uses: paperclipai/paperclip/.github/workflows/ci-routing.yml@master policy: needs: [gate] diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 0a182b1cccc..05ef1de1e56 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -227,17 +227,17 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.qualification_source_sha }} persist-credentials: false - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 with: version: 9.15.4 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: 24 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.qualification_daemons.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs @@ -287,7 +287,7 @@ jobs: fi - name: Retain completed package assembly even after a later Linux failure if: ${{ !cancelled() && steps.pack_identity.outputs.available == 'true' }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ inputs.artifact_name }}-public-packages path: ${{ runner.temp }}/public-runner-packs/ @@ -304,17 +304,17 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.qualification_daemons.outputs.native_consumer_matrix) }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.qualification_source_sha }} persist-credentials: false - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 with: version: 9.15.4 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: 24 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: ${{ inputs.artifact_name }}-public-packages path: ${{ runner.temp }}/public-runner-packs @@ -322,9 +322,8 @@ jobs: run: pnpm install --ignore-scripts --no-frozen-lockfile - name: Checkout immutable ARM qualification tooling if: ${{ matrix.target == 'linux-arm64' }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - # job.workflow_* identifies this reusable workflow, not its caller. repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .qualification-tooling @@ -375,7 +374,7 @@ jobs: if [[ "$TARGET" == linux-arm64 ]]; then mode=--linux-arm64-consumer; fi node scripts/verify-grok-npm-install.mjs "$mode" \ "$RUNNER_TEMP/public-runner-packs" "$SOURCE_SHA" "$RUNNER_TEMP/native-public-install.json" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: name: ${{ inputs.artifact_name }}-${{ matrix.target }} diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 862821d0bce..13b902e2dd1 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -529,11 +529,11 @@ jobs: matrix: ${{ steps.targets.outputs.matrix }} native_consumers: ${{ steps.targets.outputs.native_consumers }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.ref }} persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: 24 - name: Preserve the immutable source's platform contract @@ -566,8 +566,6 @@ jobs: && Object.keys(contract).sort().join() === 'schema,targets', 'Unknown exact-source daemon platform contract'); declared = contract.targets; } else { - // Frozen pre-contract sources used a literal export. Read that one - // declaration as JSON data; never import or evaluate preview code. const legacy = readBlob('packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'); const declarations = [...(legacy ?? '').matchAll(/^[ \t]*export const RELEASE_RUNNER_TARGETS = (?:Object\.freeze\()?(\[[^\r\n]*\])\)?;[ \t]*$/gm)]; assert.equal(declarations.length, 1, 'Unknown exact-source daemon platform contract'); @@ -599,14 +597,14 @@ jobs: artifact_prefix: ${{ steps.identity.outputs.artifact_prefix }} provider_pack_artifact_prefix: ${{ steps.identity.outputs.provider_pack_artifact_prefix }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.ref }} persist-credentials: false - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 with: version: 9.15.4 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: 24.21.0 - name: Bind immutable producer source and target @@ -646,18 +644,13 @@ jobs: fi rustup toolchain list - name: Cache native Rust dependencies - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 with: workspaces: ${{ steps.native_rust_workspace.outputs.path }} -> target - # Compiler/environment and Cargo lock hashes are added by the action. - # Also bind generated contracts outside the Rust workspace, while - # always recompiling workspace code and recording actual new bytes. shared-key: release-native-v2-${{ matrix.host }}-${{ matrix.target }}-${{ hashFiles('packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/src/**', 'packages/paperclip-runner/*.json') }} cache-workspace-crates: false cache-bin: false cache-on-failure: false - # A master workflow can verify an unmerged preview ref. Those jobs - # restore only; they never write master's trusted dependency cache. save-if: ${{ github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }} - name: Install dependencies run: pnpm install --no-frozen-lockfile @@ -700,7 +693,7 @@ jobs: run: | node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs record "$SOURCE_SHA" "$TARGET" \ packages/paperclip-runner/dist/bin/paperclip-runnerd "$RUNNER_TEMP/runner-release/$TARGET" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: ${{ steps.identity.outputs.artifact_prefix }}-${{ matrix.target }} path: ${{ runner.temp }}/runner-release/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c2ad4e16c2..4c608ce9a09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -341,8 +341,6 @@ jobs: run: node scripts/cloud-source-verification.mjs "$SOURCE_SHA" release_runner_source: - # Old promotions retain their already-shipped packaging contract. New - # sources opt into source-declared native target assembly in their release.sh. needs: [verify_canary, select_nightly, select_beta, preflight_stable] if: >- !cancelled() && ( @@ -357,7 +355,7 @@ jobs: sha: ${{ steps.source.outputs.sha }} required: ${{ steps.source.outputs.required }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ needs.preflight_stable.outputs.sha || needs.select_beta.outputs.sha || needs.select_nightly.outputs.sha || github.sha }} persist-credentials: false @@ -427,7 +425,7 @@ jobs: - name: Download exact-source release daemon receipts if: needs.release_runner_source.outputs.required == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs @@ -734,7 +732,7 @@ jobs: - name: Download exact-source release daemon receipts if: needs.release_runner_source.outputs.required == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs @@ -1023,7 +1021,7 @@ jobs: - name: Download exact-source release daemon receipts if: needs.release_runner_source.outputs.required == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs @@ -1414,7 +1412,7 @@ jobs: - name: Download exact-source release daemon receipts if: needs.release_runner_source.outputs.required == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs @@ -1482,7 +1480,7 @@ jobs: - name: Download exact-source release daemon receipts if: needs.release_runner_source.outputs.required == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* path: ${{ runner.temp }}/release-runner-inputs diff --git a/AGENTS.md b/AGENTS.md index 55ee4734b84..c7b53d20d43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,6 +162,12 @@ Follow [the map contract](maps/README.md): update affected entries when mapped behavior or access changes, and run `node scripts/check-maps.mjs` after map edits. Its structural checks do not prove live behavior or authorization. +Storybook has a downstream [feature catalog](maps/storybook-map/README.md). +Use `maintain-storybook` for story/fixture drift and `review-storybook` for browser +evidence. Keep the feature-map skills independent of this catalog. After story +or declared review-source changes, reconcile affected entries and run +`node scripts/storybook-catalog.mjs`; its checks are not production acceptance. + Default local/agent test path: ```sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31033ea98ca..dbb0efceceb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,9 @@ PRs that follow this path are **much** more likely to be accepted, even when the ## PR Requirements (all PRs) +- Do not submit a PR that moves a code file from less than 1,000 lines of code to more than 1,000 lines of code. +- Do not submit a PR that includes code comments. + ### Use the PR Template Every pull request **must** follow the PR template at [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). If you create a PR via the GitHub API or other tooling that bypasses the template, copy its contents into your PR description manually. The template includes required sections: Thinking Path, What Changed, Verification, Risks, Model Used, and a Checklist. diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index ed693145875..fbe3fc0786b 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -62,6 +62,109 @@ function company(overrides: Record = {}) { }; } +function allImportEntities(): CompanyPortabilityPreviewResult["include"] { + return { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }; +} + +function sourceCompanyManifest(): NonNullable { + return { + path: "COMPANY.md", + name: "Source Co", + description: null, + logoPath: null, + requireBoardApprovalForNewAgents: false, + feedbackDataSharingEnabled: false, + feedbackDataSharingConsentAt: null, + feedbackDataSharingConsentByUserId: null, + feedbackDataSharingTermsVersion: null, + }; +} + +function ceoManifest(): CompanyPortabilityPreviewResult["manifest"]["agents"][number] { + return { + slug: "ceo", + name: "CEO", + path: "agents/ceo/AGENT.md", + skills: [], + role: "ceo", + title: null, + icon: null, + capabilities: null, + reportsToSlug: null, + reportsToExistingAgentId: null, + reportsToExistingAgentSlug: null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + permissionGrants: [], + budgetMonthlyCents: 0, + metadata: null, + }; +} + +function alphaProjectManifest(): CompanyPortabilityPreviewResult["manifest"]["projects"][number] { + return { + slug: "alpha", + name: "Alpha", + path: "projects/alpha/PROJECT.md", + description: null, + ownerAgentSlug: null, + leadAgentSlug: null, + targetDate: null, + color: null, + icon: null, + status: null, + executionWorkspacePolicy: null, + workspaces: [], + env: null, + metadata: null, + }; +} + +function kickoffIssueManifest(): CompanyPortabilityPreviewResult["manifest"]["issues"][number] { + return { + slug: "kickoff", + identifier: null, + title: "Kickoff", + path: "projects/alpha/issues/kickoff/TASK.md", + projectSlug: "alpha", + projectWorkspaceKey: null, + assigneeAgentSlug: "ceo", + description: null, + recurring: false, + routine: null, + legacyRecurrence: null, + status: null, + priority: null, + labelIds: [], + billingCode: null, + executionWorkspaceSettings: null, + assigneeAdapterOverrides: null, + comments: [], + metadata: null, + }; +} + +function openAiEnvInput(): CompanyPortabilityPreviewResult["envInputs"][number] { + return { + key: "OPENAI_API_KEY", + description: null, + agentSlug: "ceo", + projectSlug: null, + kind: "secret", + requirement: "required", + defaultValue: null, + portability: "portable", + }; +} + describe("company CLI commands", () => { let fetchMock: ReturnType; let logSpy: ReturnType; @@ -298,13 +401,7 @@ describe("buildCompanyDashboardUrl", () => { describe("renderCompanyImportPreview", () => { it("summarizes the preview with counts, selection info, and truncated examples", () => { const preview: CompanyPortabilityPreviewResult = { - include: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, + include: allImportEntities(), targetCompanyId: "company-123", targetCompanyName: "Imported Co", collisionStrategy: "rename", @@ -334,49 +431,14 @@ describe("renderCompanyImportPreview", () => { companyId: "company-src", companyName: "Source Co", }, - includes: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, - company: { - path: "COMPANY.md", - name: "Source Co", - description: null, - logoPath: null, - requireBoardApprovalForNewAgents: false, - feedbackDataSharingEnabled: false, - feedbackDataSharingConsentAt: null, - feedbackDataSharingConsentByUserId: null, - feedbackDataSharingTermsVersion: null, - }, + includes: allImportEntities(), + company: sourceCompanyManifest(), sidebar: { agents: ["ceo"], projects: ["alpha"], }, agents: [ - { - slug: "ceo", - name: "CEO", - path: "agents/ceo/AGENT.md", - skills: [], - role: "ceo", - title: null, - icon: null, - capabilities: null, - reportsToSlug: null, - reportsToExistingAgentId: null, - reportsToExistingAgentSlug: null, - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - permissionGrants: [], - budgetMonthlyCents: 0, - metadata: null, - }, + ceoManifest(), ], skills: [ { @@ -395,73 +457,20 @@ describe("renderCompanyImportPreview", () => { }, ], projects: [ - { - slug: "alpha", - name: "Alpha", - path: "projects/alpha/PROJECT.md", - description: null, - ownerAgentSlug: null, - leadAgentSlug: null, - targetDate: null, - color: null, - icon: null, - status: null, - executionWorkspacePolicy: null, - workspaces: [], - env: null, - metadata: null, - }, + alphaProjectManifest(), ], issues: [ - { - slug: "kickoff", - identifier: null, - title: "Kickoff", - path: "projects/alpha/issues/kickoff/TASK.md", - projectSlug: "alpha", - projectWorkspaceKey: null, - assigneeAgentSlug: "ceo", - description: null, - recurring: false, - routine: null, - legacyRecurrence: null, - status: null, - priority: null, - labelIds: [], - billingCode: null, - executionWorkspaceSettings: null, - assigneeAdapterOverrides: null, - comments: [], - metadata: null, - }, + kickoffIssueManifest(), ], envInputs: [ - { - key: "OPENAI_API_KEY", - description: null, - agentSlug: "ceo", - projectSlug: null, - kind: "secret", - requirement: "required", - defaultValue: null, - portability: "portable", - }, + openAiEnvInput(), ], }, files: { "COMPANY.md": "# Source Co", }, envInputs: [ - { - key: "OPENAI_API_KEY", - description: null, - agentSlug: "ceo", - projectSlug: null, - kind: "secret", - requirement: "required", - defaultValue: null, - portability: "portable", - }, + openAiEnvInput(), ], warnings: ["One warning"], errors: ["One error"], @@ -545,13 +554,7 @@ describe("renderCompanyImportResult", () => { describe("import selection catalog", () => { it("defaults to everything and keeps project selection separate from task selection", () => { const preview: CompanyPortabilityPreviewResult = { - include: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, + include: allImportEntities(), targetCompanyId: "company-123", targetCompanyName: "Imported Co", collisionStrategy: "rename", @@ -569,13 +572,7 @@ describe("import selection catalog", () => { companyId: "company-src", companyName: "Source Co", }, - includes: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, + includes: allImportEntities(), company: { path: "COMPANY.md", name: "Source Co", @@ -592,26 +589,7 @@ describe("import selection catalog", () => { projects: ["alpha"], }, agents: [ - { - slug: "ceo", - name: "CEO", - path: "agents/ceo/AGENT.md", - skills: [], - role: "ceo", - title: null, - icon: null, - capabilities: null, - reportsToSlug: null, - reportsToExistingAgentId: null, - reportsToExistingAgentSlug: null, - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - permissionGrants: [], - budgetMonthlyCents: 0, - metadata: null, - }, + ceoManifest(), ], skills: [ { @@ -630,45 +608,10 @@ describe("import selection catalog", () => { }, ], projects: [ - { - slug: "alpha", - name: "Alpha", - path: "projects/alpha/PROJECT.md", - description: null, - ownerAgentSlug: null, - leadAgentSlug: null, - targetDate: null, - color: null, - icon: null, - status: null, - executionWorkspacePolicy: null, - workspaces: [], - env: null, - metadata: null, - }, + alphaProjectManifest(), ], issues: [ - { - slug: "kickoff", - identifier: null, - title: "Kickoff", - path: "projects/alpha/issues/kickoff/TASK.md", - projectSlug: "alpha", - projectWorkspaceKey: null, - assigneeAgentSlug: "ceo", - description: null, - recurring: false, - routine: null, - legacyRecurrence: null, - status: null, - priority: null, - labelIds: [], - billingCode: null, - executionWorkspaceSettings: null, - assigneeAdapterOverrides: null, - comments: [], - metadata: null, - }, + kickoffIssueManifest(), ], envInputs: [], }, @@ -720,13 +663,7 @@ describe("import selection catalog", () => { it("includes extension file even when all entities are deselected", () => { const preview: CompanyPortabilityPreviewResult = { - include: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, + include: allImportEntities(), targetCompanyId: "company-123", targetCompanyName: "Imported Co", collisionStrategy: "rename", @@ -744,24 +681,8 @@ describe("import selection catalog", () => { companyId: "company-src", companyName: "Source Co", }, - includes: { - company: true, - agents: true, - projects: true, - issues: true, - skills: true, - }, - company: { - path: "COMPANY.md", - name: "Source Co", - description: null, - logoPath: null, - requireBoardApprovalForNewAgents: false, - feedbackDataSharingEnabled: false, - feedbackDataSharingConsentAt: null, - feedbackDataSharingConsentByUserId: null, - feedbackDataSharingTermsVersion: null, - }, + includes: allImportEntities(), + company: sourceCompanyManifest(), sidebar: { agents: [], projects: [], diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index e4e57e09bfa..4396c2716c2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -285,9 +285,37 @@ function buildSourceConfig(): PaperclipConfig { }; } +function writeSeedConfigFiles( + sourceConfigPath: string, + targetConfigPath: string, + targetRoot: string, + targetPaths: ReturnType, + sourceConfig: PaperclipConfig, + targetConfig: PaperclipConfig, +): void { + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); + fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); + fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); + fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); + fs.writeFileSync( + path.join(targetRoot, ".paperclip", ".env"), + `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, + ); +} + +function initializeTestRepo(repoRoot: string): void { + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); +} + describe("worktree helpers", () => { it("uses the repo-local config for the current worktree", () => { - const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-current-worktree-")); + const targetRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-current-worktree-")); try { const localConfig = path.join(targetRoot, ".paperclip", "config.json"); fs.mkdirSync(path.dirname(localConfig), { recursive: true }); @@ -307,7 +335,7 @@ describe("worktree helpers", () => { }); it("uses the repository config from a nested working directory", () => { - const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-current-worktree-nested-")); + const targetRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-current-worktree-nested-")); try { execFileSync("git", ["init", "-q"], { cwd: targetRoot }); const nestedDirectory = path.join(targetRoot, "packages", "example", "src"); @@ -517,7 +545,7 @@ describe("worktree helpers", () => { }); it("requires the seed process to own the target embedded Postgres lifecycle", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-live-target-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-live-target-")); try { fs.writeFileSync( path.join(tempRoot, "postmaster.pid"), @@ -531,18 +559,6 @@ describe("worktree helpers", () => { } }); - it("surfaces a credential-safe diagnostic when the target shuts down during restore", () => { - expect(formatWorktreeSeedFailureDiagnostic( - "restore", - new Error( - "Failed to restore seed.sql.gz: FATAL: the database system is shutting down; psql error: write EPIPE", - ), - )).toBe( - "Target embedded PostgreSQL shut down during restore. Stop any competing worktree service and retry the seed.", - ); - expect(formatWorktreeSeedFailureDiagnostic("migrations", new Error("secret connection failure"))) - .toBe("Seed failed during migrations."); - }); it("surfaces the missing credential artifact for authenticated seed validation", () => { expect(formatWorktreeSeedFailureDiagnostic( @@ -610,7 +626,7 @@ describe("worktree helpers", () => { itEmbeddedPostgres("recognizes positive legacy database schema evidence", async () => { const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-legacy-evidence-"); onTestFinished(() => tempDb.cleanup()); - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-legacy-config-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-legacy-config-")); try { const configPath = path.join(tempRoot, "config.json"); const sourceConfig = buildSourceConfig(); @@ -644,7 +660,7 @@ describe("worktree helpers", () => { }); it("ensure-seeded seeds once and fast-exits on the verified manifest", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-ensure-seeded-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -661,15 +677,7 @@ describe("worktree helpers", () => { serverPort: 3199, databasePort: 54999, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); const seedDatabase = vi.fn().mockResolvedValue({ @@ -715,7 +723,7 @@ describe("worktree helpers", () => { }); it("treats an unregistered markerless config as a normal non-worktree boot", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unregistered-markerless-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-unregistered-markerless-")); try { const configPath = path.join(tempRoot, "config.json"); fs.writeFileSync(configPath, `${JSON.stringify(buildSourceConfig())}\n`); @@ -740,7 +748,7 @@ describe("worktree helpers", () => { }); it("honors a legacy complete marker without resolving a seed source", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-complete-marker-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-complete-marker-")); try { const configPath = path.join(tempRoot, "config.json"); fs.writeFileSync(configPath, `${JSON.stringify(buildSourceConfig())}\n`); @@ -763,7 +771,7 @@ describe("worktree helpers", () => { }); it("seeds a configured worktree with no seed markers when no legacy database is present", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unmarked-empty-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-unmarked-empty-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -780,15 +788,7 @@ describe("worktree helpers", () => { serverPort: 3194, databasePort: 54994, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); const inspectLegacyDatabase = vi.fn().mockResolvedValue(null); const seedDatabase = vi.fn().mockResolvedValue(mockVerifiedSeedResult()); @@ -810,7 +810,7 @@ describe("worktree helpers", () => { }); it("adopts a markerless legacy worktree only after validating its database schema", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unmarked-legacy-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-unmarked-legacy-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -827,15 +827,7 @@ describe("worktree helpers", () => { serverPort: 3193, databasePort: 54993, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); const seedDatabase = vi.fn(); await expect(ensureWorktreeSeeded( @@ -858,7 +850,7 @@ describe("worktree helpers", () => { }); it("managed ensure-seeded derives a valid source from the registered base workspace", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-managed-seed-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-managed-seed-")); try { const baseRoot = path.join(tempRoot, "base"); const sourceConfigPath = path.join(baseRoot, ".paperclip", "config.json"); @@ -907,7 +899,7 @@ describe("worktree helpers", () => { it.each(["sibling", "foreign_instance", "symlink", "instance_mismatch"] as const)( "managed ensure-seeded re-derives a stale %s manifest source from registration", async (variant) => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `paperclip-worktree-managed-${variant}-`)); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), `paperclip-worktree-managed-${variant}-`)); try { const baseRoot = path.join(tempRoot, "base"); const canonicalSource = path.join(baseRoot, ".paperclip", "config.json"); @@ -981,7 +973,9 @@ describe("worktree helpers", () => { ); it("ensure-seeded records a target shutdown diagnostic when restore fails", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-failure-")); + expect(formatWorktreeSeedFailureDiagnostic("migrations", new Error("secret connection failure"))) + .toBe("Seed failed during migrations."); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-ensure-seeded-failure-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -998,15 +992,7 @@ describe("worktree helpers", () => { serverPort: 3198, databasePort: 54998, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); await expect( @@ -1044,7 +1030,7 @@ describe("worktree helpers", () => { }); it("serializes concurrent ensure-seeded calls across the seed marker lock", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-lock-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-ensure-seeded-lock-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -1061,15 +1047,7 @@ describe("worktree helpers", () => { serverPort: 3197, databasePort: 54997, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); const seedDatabase = vi.fn(async () => { @@ -1094,7 +1072,7 @@ describe("worktree helpers", () => { }); it("records an interrupted phase before retrying to a verified terminal state", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-interrupted-seed-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-interrupted-seed-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "worktree"); @@ -1111,15 +1089,7 @@ describe("worktree helpers", () => { serverPort: 3196, databasePort: 54996, }); - fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); - fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); - fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); - fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); - fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); - fs.writeFileSync( - path.join(targetRoot, ".paperclip", ".env"), - `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, - ); + writeSeedConfigFiles(sourceConfigPath, targetConfigPath, targetRoot, targetPaths, sourceConfig, targetConfig); markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); const interrupted = readWorktreeSeedManifest(targetConfigPath)!; fs.writeFileSync( @@ -1147,7 +1117,7 @@ describe("worktree helpers", () => { }); it("fails closed instead of racing to reclaim a stale seed lock", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-stale-lock-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-ensure-seeded-stale-lock-")); try { const targetConfigPath = path.join(tempRoot, ".paperclip", "config.json"); const lockPath = path.join(tempRoot, ".paperclip", "seed.lock"); @@ -1413,7 +1383,7 @@ describe("worktree helpers", () => { }); it("copies the source local_encrypted secrets key into the seeded worktree instance", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-secrets-")); const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; try { @@ -1452,7 +1422,7 @@ describe("worktree helpers", () => { }); it("writes the source inline secrets master key into the seeded worktree instance", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-secrets-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); @@ -1473,7 +1443,7 @@ describe("worktree helpers", () => { }); it("creates an explicitly empty worktree without inherited signing secrets or deferred copying", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-empty-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-empty-")); const originalCwd = process.cwd(); const originalJwt = process.env.PAPERCLIP_AGENT_JWT_SECRET; const originalSigning = process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET; @@ -1503,7 +1473,7 @@ describe("worktree helpers", () => { }); it("persists the current agent jwt secret into the worktree env file", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-jwt-")); const repoRoot = path.join(tempRoot, "repo"); const originalCwd = process.cwd(); const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; @@ -1544,7 +1514,7 @@ describe("worktree helpers", () => { }); it("preserves repo-managed worktree checkouts when --force re-runs from the source repo", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-force-preserve-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-force-preserve-")); const repoRoot = path.join(tempRoot, "repo"); const originalCwd = process.cwd(); @@ -1585,7 +1555,7 @@ describe("worktree helpers", () => { itEmbeddedPostgres( "seeds a local-trusted implicit board user without a credential account", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-local-board-seed-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-local-board-seed-")); const originalCwd = process.cwd(); onTestFinished(() => { process.chdir(originalCwd); @@ -1687,7 +1657,7 @@ describe("worktree helpers", () => { itEmbeddedPostgres( "seeds a lagging source whose migration application order differs from filename order", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-auth-seed-")); const originalCwd = process.cwd(); onTestFinished(() => { process.chdir(originalCwd); @@ -1866,7 +1836,7 @@ describe("worktree helpers", () => { ); it("avoids ports already claimed by sibling worktree instance configs", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-claimed-ports-")); const repoRoot = path.join(tempRoot, "repo"); const homeDir = path.join(tempRoot, ".paperclip-worktrees"); const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree"); @@ -1947,7 +1917,7 @@ describe("worktree helpers", () => { }); it("reserves distinct ports for postgres-mode siblings under a custom worktree parent", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-custom-parent-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-custom-parent-")); const homeDir = path.join(tempRoot, ".paperclip-worktrees"); const customParentDir = path.join(tempRoot, "custom", "workspace-lanes"); const firstWorktreeRoot = path.join(customParentDir, "lane-one"); @@ -2002,7 +1972,7 @@ describe("worktree helpers", () => { }); it("defaults the seed source config to the current repo-local Paperclip config", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-source-config-")); const repoRoot = path.join(tempRoot, "repo"); const localConfigPath = path.join(repoRoot, ".paperclip", "config.json"); const originalCwd = process.cwd(); @@ -2027,7 +1997,7 @@ describe("worktree helpers", () => { }); it("preserves the source config path across worktree:make cwd changes", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-source-override-")); const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const targetRoot = path.join(tempRoot, "target"); const originalCwd = process.cwd(); @@ -2070,7 +2040,7 @@ describe("worktree helpers", () => { }); it("derives worktree reseed target paths from the adjacent env file", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-reseed-target-")); const worktreeRoot = path.join(tempRoot, "repo"); const configPath = path.join(worktreeRoot, ".paperclip", "config.json"); const envPath = path.join(worktreeRoot, ".paperclip", ".env"); @@ -2102,7 +2072,7 @@ describe("worktree helpers", () => { }); it("rejects reseed targets without worktree env metadata", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-missing-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-reseed-target-missing-")); const worktreeRoot = path.join(tempRoot, "repo"); const configPath = path.join(worktreeRoot, ".paperclip", "config.json"); @@ -2127,7 +2097,7 @@ describe("worktree helpers", () => { }); itEmbeddedPostgres("reseed preserves the current worktree ports, instance id, and branding", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-reseed-")); const repoRoot = path.join(tempRoot, "repo"); const sourceRoot = path.join(tempRoot, "source"); const homeDir = path.join(tempRoot, ".paperclip-worktrees"); @@ -2232,7 +2202,7 @@ describe("worktree helpers", () => { }); it("restores the current worktree config and instance data if reseed fails", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-reseed-rollback-")); const repoRoot = path.join(tempRoot, "repo"); const sourceRoot = path.join(tempRoot, "source"); const homeDir = path.join(tempRoot, ".paperclip-worktrees"); @@ -2341,18 +2311,13 @@ describe("worktree helpers", () => { }); it("copies shared git hooks into a linked worktree git dir", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-hooks-")); const repoRoot = path.join(tempRoot, "repo"); const worktreePath = path.join(tempRoot, "repo-feature"); try { fs.mkdirSync(repoRoot, { recursive: true }); - execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + initializeTestRepo(repoRoot); const sourceHooksDir = path.join(repoRoot, ".git", "hooks"); const sourceHookPath = path.join(sourceHooksDir, "pre-commit"); @@ -2389,7 +2354,7 @@ describe("worktree helpers", () => { }, 15_000); it("creates and initializes a worktree from the top-level worktree:make command", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-make-")); const repoRoot = path.join(tempRoot, "repo"); const fakeHome = path.join(tempRoot, "home"); const worktreePath = path.join(fakeHome, "paperclip-make-test"); @@ -2399,12 +2364,7 @@ describe("worktree helpers", () => { try { fs.mkdirSync(repoRoot, { recursive: true }); fs.mkdirSync(fakeHome, { recursive: true }); - execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + initializeTestRepo(repoRoot); process.chdir(repoRoot); @@ -2424,18 +2384,13 @@ describe("worktree helpers", () => { }, 20_000); it("no-ops on the primary checkout unless --branch is provided", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-primary-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-repair-primary-")); const repoRoot = path.join(tempRoot, "repo"); const originalCwd = process.cwd(); try { fs.mkdirSync(repoRoot, { recursive: true }); - execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + initializeTestRepo(repoRoot); process.chdir(repoRoot); await worktreeRepairCommand({}); @@ -2449,7 +2404,7 @@ describe("worktree helpers", () => { }); it("repairs the current linked worktree when Paperclip metadata is missing", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-current-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-repair-current-")); const repoRoot = path.join(tempRoot, "repo"); const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", "repair-me"); const sourceConfigPath = path.join(tempRoot, "source-config.json"); @@ -2463,12 +2418,7 @@ describe("worktree helpers", () => { try { fs.mkdirSync(repoRoot, { recursive: true }); - execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + initializeTestRepo(repoRoot); fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); execFileSync("git", ["worktree", "add", "-b", "repair-me", worktreePath, "HEAD"], { cwd: repoRoot, @@ -2496,7 +2446,7 @@ describe("worktree helpers", () => { }, 20_000); it("creates and repairs a missing branch worktree when --branch is provided", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-branch-")); + const tempRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-worktree-repair-branch-")); const repoRoot = path.join(tempRoot, "repo"); const sourceConfigPath = path.join(tempRoot, "source-config.json"); const worktreeHome = path.join(tempRoot, ".paperclip-worktrees"); @@ -2505,12 +2455,7 @@ describe("worktree helpers", () => { try { fs.mkdirSync(repoRoot, { recursive: true }); - execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); - fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + initializeTestRepo(repoRoot); fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8"); process.chdir(repoRoot); diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2177689e1b9..5862fe40a55 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -701,6 +701,18 @@ this non-secret binding for this namespace; Slack bot installation does not use PKCE. Removal and manual recovery invalidate outstanding attempts under the same credential-mutation lease used by configuration. +### Slack manager workspace grants + +`chat_slack_manager_grants` stores a personal workspace authorization, scoped by +company, Paperclip user, Slack user, workspace and manager app. Access/refresh +credentials are references to user-scoped vault entries, never plaintext columns. +Revision fencing protects reauthorization from concurrent revocation. Per-method +rate-limit deadlines retain Slack's Retry-After across setup retries. + +`chat_slack_registrations.manager_grant_id` is optional, with a composite company +foreign key. Existing registrations remain customer owned with a null reference. +Deleting one endpoint removes its registration credentials and attempts, but does +not revoke grants used by other bots. See [Slack setup](connections/slack-setup.md). ## Transaction-aware delivery work signals diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index afe26b16c4f..83069345c7b 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -35,6 +35,28 @@ New workflow versions merged into master then receive runner access without a separate SHA allowlist update. Dependabot leaves this first-party reference on master. +Runner selection calls `ci-routing.yml@master` on the dedicated +`paperclip-ci-routing-x64` AWS Fleet. The `paperclip-ci-routing` runner group +admits only `paperclipai/paperclip/.github/workflows/ci-routing.yml@refs/heads/master` +and repository ID `1170821064`. GitHub evaluates the canonical repository, author, and sender checks before +allocating reserved capacity. The validated admission snapshot (`AWS_CI_ROUTING_USER_IDS_SOURCE` and +`AWS_CI_ROUTING_USER_IDS`) must match `AWS_CI_TRUSTED_USER_IDS`. Refresh it through +the runner-group configuration helper when the roster changes. Missing, stale, +or malformed settings keep hosted routing available without evaluating JSON +in the job's placement expression. Unapproved PRs use hosted metadata routing; +approved manual canaries on master can use the reserved pool. The routing +workflow reads GitHub metadata without checking out or executing PR code. Its author, sender, rerun actor, +repository, head, and merge validation still decide whether tests can use AWS. +Unapproved test workloads continue to use GitHub-hosted runners. + +The routing Fleet has two warm On-Demand runners and a four-runner ceiling. +Test fleets cannot consume its runner slots. Hosted test queues therefore do +not block AWS runner selection. A manual dispatch of `ci-routing.yml` on master +checks placement without launching the test matrix; its non-PR event returns +the hosted test-runner output. Configure and verify the group and Fleet before +merging this caller. To roll back, restore the hosted gate and retain the routing +workflow authorization until assigned jobs finish. + Keep the `.github/**` rule in `.github/CODEOWNERS` and the active master ruleset's code-owner review requirement enabled. This covers the caller, the trusted workflow, and CODEOWNERS itself. Existing administrator pull-request bypasses @@ -102,6 +124,20 @@ API responses and MCP transports keep their existing compression behavior. The board UI Storybook keeps stories and Storybook config under `ui/storybook/` so component review files stay out of the app source routes. +Stories are grouped in `ui/storybook/stories//` and the sidebar follows +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. + +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 +source-review status. Use `maintain-storybook` to reconcile stories/fixtures with +the feature map, and `review-storybook` to exercise them with existing tools. +Run `node scripts/storybook-catalog.mjs` after affected story or review-source +changes; review drift before updating fingerprints, then regenerate with `--write`. +The feature map remains an upstream input and needs no Storybook-specific fields. + **Chat & Comments → Human identity** covers other-human names and avatars on left-aligned task messages alongside agents; only the viewer’s own messages are blue/right-aligned. Its review guide explains the data flow; the task journey uses representative fixtures in the production shell and thread with an agent and two humans, both @@ -2263,3 +2299,12 @@ Chromium. The test starts its own loopback Vite server and mocks API responses; it needs no running Paperclip instance or provider credentials. The same spec lives in the default `test:e2e` discovery tree, so the existing Chrome CI shards run it on pull requests. + +### Slack provisioning paths + +Self-hosted development continues to use independent Slack app setup. Managed +Slack installation is a disabled-by-default Cloud feature that also requires the +companion broker profile and Slack approval. Do not add manager client credentials +to a local `.env` or an open-source deployment. See +[Slack setup and Cloud qualification](connections/slack-setup.md) for the gates, +recovery behavior, and production-component stories. diff --git a/doc/architecture/runner-pi-capabilities.md b/doc/architecture/runner-pi-capabilities.md index 8b57d05c844..253fa9296ff 100644 --- a/doc/architecture/runner-pi-capabilities.md +++ b/doc/architecture/runner-pi-capabilities.md @@ -2,7 +2,7 @@ ## Current source integration — 2026-10-12 -Pi profile 24 (`sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b`) +Pi profile 25 (`sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a`) advances the shared sandbox source attestation after Claude settings initialization moved behind provider lifetime ownership. Profile 23 remains an immutable historical identity. Pi's provider executable closures, model/effort options and diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index 8ad361096f3..90137ed4c97 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -2,7 +2,7 @@ ## Current source integration — 2026-10-12 -Pi profile 24 (`sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b`) +Pi profile 25 (`sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a`) advances the shared sandbox source attestation after Claude settings initialization moved behind provider lifetime ownership. Profile 23 remains an immutable historical identity. Pi's provider executable closures, model/effort options and diff --git a/doc/connection-intents.md b/doc/connection-intents.md index ef848e2abf1..c41e5d129bd 100644 --- a/doc/connection-intents.md +++ b/doc/connection-intents.md @@ -77,4 +77,4 @@ Task setup defaults to personal identity when supported and the requesting agent Service and native-authority tests cover discovery, current identity, company boundaries, cross-run deduplication, additive grants and installs, permission denial, resolution atomicity, restart delivery and stale assignment. `tests/e2e/in-feed-native/playwright.config.ts` starts source `test-drive` instances with fresh data directories and a deterministic fake Codex provider plus MCP server. It exercises the real native runner and gateway; it is fixture proof, not live Notion or GitHub proof. Run with `pnpm exec playwright test -c tests/e2e/in-feed-native/playwright.config.ts`. -Offline Storybook examples live in `ui/storybook/stories/in-feed-connections.stories.tsx`. Build with `pnpm --filter @paperclipai/ui build-storybook`, then run `pnpm exec playwright test -c tests/storybook-visual/in-feed-connections.config.ts`. The suite checks every independently addressable story in both themes, catches play-function failures, and saves screenshots. Live provider acceptance additionally requires a model credential and a test workspace/account; do not describe fixture results or local-trusted testing as authenticated/cloud acceptance. +Offline Storybook examples live in `ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx`. Build with `pnpm --filter @paperclipai/ui build-storybook`, then run `pnpm exec playwright test -c tests/storybook-visual/in-feed-connections.config.ts`. The suite checks every independently addressable story in both themes, catches play-function failures, and saves screenshots. Live provider acceptance additionally requires a model credential and a test workspace/account; do not describe fixture results or local-trusted testing as authenticated/cloud acceptance. diff --git a/doc/connections/slack-setup.md b/doc/connections/slack-setup.md new file mode 100644 index 00000000000..78abb8ef67f --- /dev/null +++ b/doc/connections/slack-setup.md @@ -0,0 +1,162 @@ +# Slack app ownership and setup + +Slack has one chat runtime and two provisioning paths. A saved connection's +`slackSetupMethod` selects provisioning; it does not change tools, scopes, +conversation routing, people permissions, or channel access. + +| Method | App creation and installation | Default | +| --- | --- | --- | +| `managed` | A personal Slack manager grant creates a child app; managed installation supplies its bot token. Slack consent/admin approval remains a fallback. | New drafts on qualified Cloud deployments only | +| `automatic` | A temporary app configuration access token creates a customer-owned app; the user approves its OAuth installation. | New self-hosted drafts and Cloud without managed availability | +| `manual` | User creates an app from the canonical manifest and enters credentials. | Legacy drafts with no saved method | +| `existing` | User supplies credentials for an existing app. | Explicit selection | + +Existing drafts and active bots retain their method. Enabling managed setup never +adopts existing apps. Choose agent offers **Use your own app** under **Advanced**, below Slash command, +when the instance supports managed setup and creation has not started. The own-app flow retains **Create manually** and **Use an existing +app** under Advanced. After creation dispatch, a managed draft cannot switch paths. + +Managed setup has two visible stops: Choose agent and Connect. **Continue** starts +manager authorization or installation in the saved workspace automatically. Multiple +workspaces still require a selection. Recovery actions remain explicit; opening +a failed or uncertain registration never retries creation automatically. +Creation, installation, message waiting, and success use the same production +components and server evidence as the own-app flow. A click or OAuth return does +not constitute successful configuration. + +## Cloud deployment prerequisites + +Managed setup is disabled by default. Qualification requires all of: + +- The instance chat-connector rollout gate and connection-management permission. +- Verified Cloud runtime identity, connector broker signing/sealing configuration, + and valid independently configured public board and webhook HTTPS origins. +- `PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED=true` on the approved tenant deployment. +- The companion Cloud broker deployment with the explicitly enabled `slack.manager` + profile, approved Slack manager app, token rotation, and its client secret in + the broker's secret store. No manager client secret belongs in the tenant. + +The broker must advertise its manager app identity as well as the enabled profile. +Previously saved grants from a different manager app are not reused. A broker +without this capability leaves managed setup unavailable. Do not enable the tenant +gate until real Slack qualification has passed; fixture coverage does not prove +Marketplace eligibility or a workspace's approval policy. + +Self-hosted instances do not need a Paperclip account, broker enrollment, manager +credentials, or hosted-service calls. They continue using the own-app flow. + +## Personal workspace authorization + +Manager grants belong to a company **and one Paperclip user**, and bind a verified +Slack user, workspace, and manager app. Another company member must authorize their +own account, even for the same workspace. Access and rotating refresh tokens are +user-scoped vault entries. Refreshes serialize across connections, persist the new +refresh token, and require fresh authorization after an ambiguous exchange. + +Revoking a grant requests provider revocation, removes its local credentials, invalidates pending manager +authorization, and prevents future provisioning with it. The local activity records whether provider revocation succeeded. Provider errors never restore local access. It does not uninstall +apps or disable already-installed bots. Removing a bot deletes endpoint-specific +secrets and pending setup attempts; a reusable personal manager grant survives. +Provider-side app removal remains explicit through the saved Slack management URL. + +## Recovery + +- A dispatched creation with an unknown result is **uncertain**. Never automatically + create again. The user must inspect Slack and explicitly confirm that no app was + created before a new request identity is accepted. +- Denied or pending installation retains the app. Resume with the same grant or + complete the child app's state-bound OAuth approval; the installer must match the + grant's Slack identity before personal linking. +- Returned bot credentials are vaulted before inventory or runtime configuration. + Retry connecting reuses those credentials after network or inventory failures. + Rejected or under-scoped bot credentials instead reinstall the saved app; they + never trigger another app creation. +- Manifest updates finish before managed installation. Changes requiring updated + permissions therefore pass through installation again before activation. +- Expired or revoked manager grants require reconnection. Losing managed + availability pauses setup; it never changes the saved ownership method. +- Slack rate limits are grouped by manager/workspace/method. Provider `Retry-After` + is persisted across matching grants in the company, survives restarts, and blocks + early retries. Other tenant instances must observe Slack's limits independently; + no background loop retries app creation. + +All OAuth attempts bind company, actor/session, endpoint, registration revision, +and configured origins, expire after ten minutes, and are claimed once before +exchange. HTTP diagnostics redact manager tokens, refresh tokens, codes, and sealed +handoffs. Local activity records contain only IDs and safe outcome codes. This adds +no first-party telemetry. + +## Code and verification + +Server provisioning lives in `server/src/services/connectors/slack/setup/`; +`chat-slack-registration.ts` remains the compatibility facade. Shared completion +owns credential binding, personal linking, welcome messages, and verification. +Removal cleanup stays independently callable. Slack wizard components and stage +selection live in `ui/src/pages/apps/chat/slack/`. + +Production Storybook journeys: **Connections / Slack / Managed setup** and +**Connections / Slack / Automatic setup** (the persisted own-app automatic path). +Server integration cases cover personal grants, duplicate requests, uncertain +creation, approval fallback, refresh, revocation, removal, saved-credential recovery, +rate limits, and secret canaries. Browser cases are in +`tests/e2e/chat-adapters-ui-providers.spec.ts`; companion broker tests cover manager +user OAuth and the authenticated tenant handoff. + +Before enabling a deployment, create two test agents in the authorized workspace. +The second must reuse the personal grant without another configuration token or +manager authorization. Verify avatars, identity linking, signed inbound events, +and same-thread replies. Record real approval behavior separately from fixtures. + +### Managed setup during broker outages + +Cloud deployments configured for managed Slack keep the managed default when the broker profile is temporarily unavailable. New drafts remain on **Connect** and offer **Retry**. **Use your own app** remains available under **Advanced** on Choose agent before creation. Discovery loading or failure never selects the configuration-token page. Self-hosted instances without managed configuration still use their independent own-app flow; saved draft methods do not change automatically. Provisioning continues to require current broker availability. + +### Local managed-bot qualification + +Maintainers with an approved Slack manager app can exercise the real managed +journey from an authenticated local test drive exposed through HTTPS (including +TryCloudflare). This development transport talks directly to Slack and reuses +the production manager-grant vault, state claims, provisioning, completion, and +runtime. It does not exercise the Cloud broker's sealed handoff; qualify that +separately on staging afterward. + +Set `NODE_ENV=development`, `PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED=true`, and +`PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG` to an absolute path outside the +checkout containing a mode-0600 JSON file with `appId`, `clientId`, and +`clientSecret`. Use only an authorized qualification manager app. No credentials +are shipped with Paperclip. Production ignores this development configuration, +and Cloud runtime identity continues to select the Cloud broker. + +Configure the local instance for authenticated public access, with its public +URL and chat webhook URL pointing to the HTTPS tunnel. Add the exact public URL +plus `/api/chat-slack/managed/oauth/callback` to the manager app's OAuth redirect +URLs, preserving any existing redirects. Start a **new** Slack setup: saved +own-app drafts retain their selected method. Authorize the manager app normally; +do not copy user grants from another instance. The second bot should reuse the +same local user's vaulted grant. A changed tunnel requires updating public +origins and the manager redirect before starting fresh authorization. + +Remove the temporary redirect when retiring the test drive and securely remove +its development credential file. Removing a bot does not revoke a manager grant +used by other bots. + +### Test messages from Settings + +**Send me a test message** sends a DM to the signed-in connection manager, +using their linked identity in this workspace. If this bot is not linked yet, +Paperclip can reuse that user's verified manager grant or another bot's linked +identity in the same company and workspace. Otherwise, the user selects their +Slack account and receives a private connection invitation in Slack. They must +open that invitation, sign in, and confirm before Paperclip links the account and +sends the test DM. The account picker loads further workspace pages on request. Existing revoked or conflicting identity links are not overwritten. +Direct messages must be enabled, and the user must be an active company operator. + +The action records a durable request before sending. Repeating the same request +returns its saved result; an uncertain delivery is never automatically resent. +**Open Slack configuration** opens the saved app's OAuth & Permissions page. +Legacy connections without an app ID open Slack's app list instead. + +An explicit managed reconnect reinstalls the saved app and verifies the replacement +bot credentials. Its request ID is saved before dispatch, so a duplicate completed +request does not reinstall again. Credential-configuration recovery still resumes +the saved token after a downstream failure. No reconnect creates another Slack app. diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 515c1273e82..8e201eff590 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,10 +2,90 @@ Updated 2026-10-12. +## Production readiness: owned startup correction + +The human requests production readiness; preparation and normal sequential +merges remain authorized. Production deployment and provisioning-default +promotion remain separate actions. Nothing in this checkpoint is merged or +deployed, and the stack is not production-ready. + +Runtime prerequisite behavior is `ce23716503a2c9158b5a7ab74ab61571ff4ee7a3` +on `codex/runner-native-platform-prerequisites`, in +[#16028](https://github.com/paperclipai/paperclip/pull/16028). Final qualification +reproduced close returning before credential preparation drained, followed by +registration and launch. Both direct close and real driver cancellation failed +on literal `5a9ce11`. The initial shared repair from Claude commit `463ebb` is +reused, with independently identified duplicate-admission, shutdown-readiness and +resume ordering corrections. All five source findings are closed in matching +source review. Absolute cleanup deadlines, primary cancellation errors, +unauthenticated-process protection and existing agents' runner choices remain. + +The existing suites pass 32 credential-free controls across three files; both +canonical runner TypeScript no-emit checks pass. No new suite, local Rust/Docker +build, provider call or credential-content read was used. Actual daemon +readiness/recovery controls remain **hosted-CI required**: local worktrees lack +the existing fake-Codex/fake-harness executables. The failed local native attempt +never reached the readiness assertion and is not qualification. Receipts are +`/private/tmp/pr2-463ebb-owned-startup-review-closure-receipt.json` and +`/private/tmp/ci-final-owned-startup-five-gap-closure-review.json`. + +Normal automatic merge is disabled and the prerequisite is draft while this +correction receives fresh full CI, package qualification and 5/5 review. +Earlier green checks at `5a9ce11` remain historical. Actual code-owner approval +is still required; no protection is bypassed. The updated runs/adapters map pair +records source-only scope, bringing the prerequisite review to 63 files after the required Storybook +catalog review below. + +Codex remains `1a0ca66242121e39d88455aa2b52a07acc4a07d9` in +[#15683](https://github.com/paperclipai/paperclip/pull/15683), based on the previous +prerequisite. The Claude owner retains its branches, cached guided-setup fix, +verification and separate $50 allowance. Coordinate reconciliation of the common +repair before rewriting those branches; don't duplicate it in a leaf slice. + +The idle [local preview](http://127.0.0.1:3125/COD/agents/all) still serves literal +behavior `fe1ecf1c34`, recovery ready and zero active runs. Its four saved +legacy/native configurations and two historical native records remain preserved; +it has not been refreshed to this new runtime. Final local/managed acceptance +must use the tested source, independent file checks and follow-up continuity. + +The prepared private execution checklist reuses existing evals, installation, +Cloud composition and campaign paths. Required holds are final source/image/ +migrator, the actual verified-master private distribution, pinned QA onboarding, +warm/cold signup, production baseline and previous-image compatibility. Production +Fleet Admin sign-in is pending. Current paid holds remain $248.179254104 of $250; +unknown/cleanup holds stay reserved, and the unstarted managed envelope is $2.50. +No new image, paid resource, rollout or default promotion was dispatched. + +Master reconciliation uses `4b958ecbd8f33a7782fe6f29ec825f80f636b833`. +The new contribution rules are satisfied: 157 added comment annotations were +removed from 35 files, with identical executable JavaScript/TypeScript emission, +parsed workflow values, 77 action pins and shell commands. No code file crosses +from below 1,000 lines to above that limit. Final focused checks pass 248 tests +(107 workflow/package, 54 runner, 42 server, 45 E2E support). Both runner no-emit +checks and the canonical server/E2E no-emit checks pass with isolated matching +workspace dependencies. Only JavaScript/TypeScript preparation ran locally; +root preview links and data stayed unchanged. The earlier linked-source failures +remain recorded and are superseded only by the new canonical check receipt. + +The existing Storybook catalog has 21 source-review fingerprint updates because +its declared runs/adapters map input changed. All affected stories, subjects, +wrappers and fixtures remain byte-identical to master. Review classifications +stay unchanged. The catalog check passes 223 files/2,210 stories; this is +structural/source evidence, not visual or live proof. Source receipts are +`/private/tmp/pr2-9f59-comments-cleanup-receipt.json`, +`/private/tmp/pr2-storybook-owned-final-receipt.json` and +`/private/tmp/pr2-frozen-canonical-noemit-receipt.json`. + +Next action: publish this reviewed prerequisite correction, require fresh hosted +gates, reconcile Codex and Claude with its actual source, then resume the existing +remote package/immutable preview and dedicated staging qualification. Merge ready +slices normally in the agreed order; actual production readiness requires the +private distribution and rollback/forward-recovery evidence afterward. + ## CI fixture, network policy and map synchronization checkpoint Runtime prerequisite source is `5a9ce11c49db31440863d633426ebe30bcc850ca` -with 60 changed files. The hosted static check on literal Codex source `eee0534` +with 59 changed files. The hosted static check on literal Codex source `eee0534` passed 133 cases and failed one: its isolated assembly fixture omitted the new target JSON, so the helper failed before its existing Cursor-identity assertion. The fixture now copies that exact inert JSON; all negative assertions remain. diff --git a/doc/plans/2026-10-12-chat-channel-delivery-boundaries-refactor.md b/doc/plans/2026-10-12-chat-channel-delivery-boundaries-refactor.md new file mode 100644 index 00000000000..01a3fb22ef9 --- /dev/null +++ b/doc/plans/2026-10-12-chat-channel-delivery-boundaries-refactor.md @@ -0,0 +1,100 @@ +# Chat channel callback and delivery boundaries, batch four + +Base: 4b766b872993045a2e787404f0475503683e5ded. Batch three (#15995, #15996, #15999, #16001, #16002) +is merged. The service starts at 26,461 lines / 1,046,711 bytes. +This batch may create up to ten stacked PRs. Merging the new batch is separate work. + +| Order / boundary | Destination | Approx. moved / net lines | Contract and state | Risks and existing verification | +| --- | --- | --- | --- | --- | +| 1. Slack callback verification | `slack/callback-verification.ts` | 250 / 230 | Pure signature/workspace/URL inspection plus signed URL verification; explicit vault lookup, URL getter, scheduling and registration callbacks | Preserve signing-secret-only setup, original request, TLS/proxy evidence and workspace checks. Signed verification and callback health cases before/after. | +| 2. Discord Gateway events | `discord/gateway-events.ts` | 200 / 185 | Runtime fence and lease-owner predicates, endpoint lookup and lifecycle callback; original per-runtime event queue and sequence | Preserve stale-owner fencing, event order, transient guild recovery and terminal revocation. Gateway lifecycle cases before/after. | +| 3. Discord root threads | `discord/root-threads.ts` | 500 / 460 | Credential lease, current principal authority, runtime, message admission and conversation drain callbacks | Preserve uncertain thread creation, restart reconciliation, no duplicate starter and pause/credential serialization. Root-thread public-service cases. | +| 4. Receipt reactions | `receipt-reactions.ts` and Slack error classifier beside Slack messages | 545 / 500 | Durable reaction claim/transport/settlement with runtime and credential fences; no scheduling owner moves | Preserve retries, legacy GitHub receipts, terminal-removal supersession and same-attempt settlement. Receipt recovery cases before/after. | +| 5. Inbound reactions | `reactions.ts` | 800 / 740 | Conversation lookup/locks, reaction admission and deferred replay together; current principal/runtime callbacks | Preserve exact event dedupe, completed generation links, bounded replay age and no task wakeup. Reaction race/restart/authorization cases. | +| 6. Inbound attachments | `inbound-attachments.ts` | 600 / 550 | Existing storage and issue service, retained source authorization and live options; return same attachment results | Preserve size limits, source authority around I/O, omissions and text delivery. Provider attachment/restart/stale-authority cases. | +| 7. Outbound attachments | `outbound-attachments.ts`; provider Markdown wrappers beside provider messages | 245 / 220 | Metadata validation and bounded integrity-checked hydration; existing storage object. Keep public hydration export compatible | Preserve stream cleanup, deadlines, byte/digest checks and retry classification. Hydration and publication attachment cases. | +| 8. Provider effects | `provider-effects.ts` | 950 / 850 | Durable reply claim, authorization, transport and settlement; endpoint/runtime/credential/principal callbacks | Preserve uncertain-delivery quarantine, credential fence and transaction ordering. Native callback, task-control and reply recovery cases. Put Telegram-only authority under `telegram/` if its contract is narrow. | +| 9. Conversation and activity reads | `activity.ts` | 540 / 520 | Endpoint lookup and read-only Teams conflict projection; return existing API models | Preserve company/endpoint joins, mixed-source cursor ties, replay eligibility and read-only GETs. Activity, file receipt projection and conversation-state cases. | +| 10. Setup probes | `setup-probes.ts` | 280 / 250 | Credential lease, current identity/principal authority, provider fetch, endpoint projection and live activation hook | Preserve real-message requirement, generation and linked-user activation. Setup-test, reconnect and authorization cases. | + +Each slice must earn its place after reading its complete path and callers. +Reassess before implementation; stop short of ten if the remaining contracts are +not cohesive. Keep provider-specific code in channel folders and common durable +workflows in the shared chat-channels directory. + +Receiver lease ownership stays shared: the existing Discord-named owner is also +used by Photon. Shared lifecycle/application admission, queue-drain ownership, +worker scheduling, and the largest action/message handlers remain in the service. +Do not hide their dependencies in a mutable context object. Preserve live option +hooks, runtime-map identity, partial-failure cleanup, and initialization order. + +Run relevant existing public-service tests before and after each mechanical move. +Compare moved declarations and remaining statements with normalized syntax, then +run server types, module boundaries and whitespace checks. Finish local verification and a fresh +5/5 review before opening the next draft PR. GitHub runner-selection jobs remained +queued without steps for over 25 minutes in this batch. To continue the authorized +stack, publish drafts in review order while tracking CI as pending; CI remains a +merge gate. Run cumulative repository gates at handoff. +Retain failures and skipped/unrun coverage even when an isolated retry passes. +Use fixture-backed assembled service/database tests; do not claim live provider +execution. Remap LOC/bytes and the remaining orchestration after the batch. + +## Slice 1: Slack callback verification + +- Service: 26,461 lines / 1,046,711 bytes → 26,201 lines / 1,035,260 bytes. +- Destination: 299 lines / 13,038 bytes. Eight declarations move without body + changes. Three existing setup types move unchanged into `runtime-types.ts`. +- The verification factory initializes after Slack registration. It receives the + same registration object, credential resolver, live URL getter and scheduler. + Signature/workspace/URL helpers remain callable by shared webhook orchestration. +- Nine public-service cases passed before and after on the updated master base. + Server typecheck, module boundaries, whitespace and normalized syntax parity + passed. Coverage includes signed URL verification, account notification, + workspace rejection, callback surfaces, TLS termination and Cloud host evidence. +- Next: Discord Gateway event sequencing and health transitions. Keep shared + receiver lease ownership and runtime-map lifetime in the service. + +## Batch outcome + +All ten candidates produced cohesive boundaries. Shared receiver ownership, queue +management and worker scheduling remain in the service. The only intentional +control-wiring change is deferred access to `stageProviderEffect` from identity +link construction; typecheck and integration tests caught the initial early read. + +| Slice | Service lines after | Primary module lines | Before/after cases | +| --- | ---: | ---: | ---: | +| 1. Slack signature, workspace and callback url verification | 26,233 | 300 | 9 | +| 2. Discord gateway event sequencing and endpoint health transitions | 26,029 | 253 | 7 | +| 3. Discord root-thread admission, provider creation and durable recovery | 25,527 | 570 | 4 | +| 4. Durable receipt reaction claims, transport, retries and settlement | 24,975 | 586 | 7 | +| 5. Inbound reaction conversation lookup, admission and deferred replay | 24,160 | 891 | 21 | +| 6. Inbound attachment authorization, bounded ingestion, registration and omissions | 23,565 | 674 | 39 | +| 7. Outbound attachment metadata validation and bounded integrity-checked hydration | 23,298 | 294 | 22 | +| 8. Durable provider reply staging, authorization, transport and recovery | 22,331 | 957 | 24 | +| 9. Conversation summaries and paginated activity projections | 21,741 | 616 | 16 | +| 10. Setup message detection and credential-fenced round-trip qualification | 21,460 | 353 | 7 | + +After rebasing on master `8e7b5db822`, the service shrank from +**26,493 lines / 1,048,769 bytes** to **21,460 lines / +862,284 bytes** (5,033 fewer lines, about 19%). Normalized syntax comparison +checks all 69 moved declarations/types and every remaining original statement. +Server types, targeted tests, module boundaries and whitespace checks are the +per-slice gates. Cumulative repository gates and remote reviews are recorded in +the PR descriptions as they complete. + +Orchestration measurement: the service function has 150 nested functions +(19,399 lines) and 67 state/wiring statements (459 lines), versus 191 functions +(23,829 lines) and 54 statements (425 lines) at the start. Imports take 400 lines. +The remaining large paths are `processMessage` (2,166), `handleAction` (1,074), +`configureWithCredentialLease` (868), `processSelectedPublication` (729), +`handleModalSubmit` (708), `handleWebhook` (685), and lifecycle processing (657). + +A future pass should inspect publication admission/transport/settlement as one +workflow, then inbound delivery authorization and drain ownership. Those cuts +need explicit ownership contracts; avoid separating individual checks from their +transactions merely to reduce the remaining line count. + +The rebase preserves the merged managed Slack setup flow, including its shared +`isProvisionedSlackSetup` predicate in the extracted callback verification +module. A fresh syntax audit compares all moved declarations and retained +statement ordering with this updated master. diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index 73e270a10d5..aea8ac20143 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -756,3 +756,84 @@ Report attachment: `e6206748-a785-4c02-9bbf-f4db8a3b22f9`, work product Local receipts: `/private/tmp/claude-responsive-preview-evidence.json`, `/private/tmp/claude-responsive-evidence-upload.json`, and `/private/tmp/claude-responsive-evidence-independent-check.json`. + +## Canonical composition and current contribution rules — 2026-10-12 + +The source owner committed the stronger shared startup repair as +`ce23716503a2c9158b5a7ab74ab61571ff4ee7a3`. Local additive merges preserve +that parent and this chat's Claude effort/resume changes. Eight controls that do +not need native executables and runner noemit pass in the combined protocol. +The ninth selected readiness-cycle case timed out while waiting for readiness; +read-only resolution confirms both the default runner artifact and fake provider +fixture are absent, with no private runner override. That native case remains a +hosted CI gate, not a pass. No local native build was performed. Logs: +`/private/tmp/claude-canonical-startup-composition-controls.log`, +`/private/tmp/claude-canonical-startup-no-process-controls.log`, and +`/private/tmp/claude-canonical-startup-composition-types.log`. + +The source owner then reconciled current master in `9f59ff36cf`, adding the +Storybook catalog and contribution rules against new code comments and code files +crossing 1000 lines. The guided-creation fixture is extracted to +`ui/src/pages/NewAgent.test-support.tsx`; all 65 cases remain and pass, with UI +noemit and token gates passing. Fixture component rendering stays inside its +mock-owning module, including the saved-confirmation reload case. The active test +file no longer crosses the threshold. Our added explanatory comments are removed; +the source owner handles the common transport's comment cleanup. Semantic code +and internal deadlines stay unchanged. + +Removing this chat's added sandbox comments changes its raw source attestation to +`144d62b3415cfedf272b5b052e14af1e41fe50a644af84247fc9c20c7ef7edde`. +Immutable Pi profiles 23 and 24 remain retained. New Pi profile 25 binds only that +source revision plus its profile version, digest +`sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a`. +SDK/executable closures, platforms, model/effort contract and other providers' +qualification boundaries do not change. Copilot remains pending profile 18. +Generated TS/Rust descriptors, Rust current/prior fixtures, Docker context and +current documentation are synchronized. All 119 profile/factory/admission/effort +controls pass with one existing historical skip, including corrupt-byte rejection +before spawn/bridge; runner noemit and generation checks pass. The first restricted +attempt stopped at loopback lease admission and is retained, not counted as byte +validation. Native/Rust execution and final package qualification remain cloud gates. + +The maintain-storybook source review found concrete fixture incompatibilities: +the general-settings endpoint and scoped runner-discovery defaults were absent. +The shared preview now returns that settings contract and explicitly simulated +Legacy discovery for Claude/Codex, exposes the loaded native adapter, and includes +Opus 5.5 while retaining explicit older-model examples. These fixture defaults are +not rollout qualification. Catalog fingerprints/classification and affected story +rendering must be reconciled after the final parent is frozen; source-only review +is not an executed Storybook or provider pass. Published draft heads remain fixed +while the source owner finishes the canonical contribution/catalog cleanup. + +## Published canonical parent and later master blocker — 2026-10-12 + +The canonical prerequisite is published as +`64c074fb8da1b31f8d6042c4fef48dc4164e759b`; all 54 exact-head checks pass +with four conditional skips, fresh 5/5 and all review threads resolved. Its hosted +native transport suite passes 246/246, closing the fake-native-fixture limitation +for that literal source. Its four provider-free producers also pass, while clean +consumers remain in progress. Those checks are not relabeled as this chat's Claude +input/profile composition. Root retains sequential normal merge/staging ownership; +code-owner approval is still required and production is not promoted. + +Local composition preserves the source repair with no duplicate lifecycle delta: +the transport diff against 64 contains only Claude effort additions. The old Codex +base branch `1a0ca66242121e39d88455aa2b52a07acc4a07d9` still lacks the current- +master parent, so publishing the current protocol would expose 468 upstream files. +The root-owned dependency must be reconciled before the review slices can again +be verified under the 100-file limit. Cross-chat dispatch still needs direct +human authorization in this chat; a concrete dependency-coordination question is +pending. No frozen root branch was mutated. + +Master then advanced to task-workspace source +`f329b24ee12496e710ad17377ec7716e0eee22ef`, making the canonical prerequisite +conflicted. The source owner is reconciling its three conflicts and requested that +leaf CI/package campaigns wait for the next published parent. All local Claude +changes and earlier package/task evidence are preserved, and no new paid turn, +local Rust/Docker build or leaf package campaign is dispatched during that hold. + +The current Storybook catalog is reconciled with 224 story files and all 2,223 +pre-move IDs preserved. Fifty-three source-affected entries retain their prior +review classifications. Six bounded fixture plays and reset pass; exact cases, +source scope, simulated boundaries and omitted coverage are recorded in +`maps/storybook-map/REVIEW.md`. This does not qualify the final installed runtime. diff --git a/doc/plans/2026-10-12-test-audit-controls.json b/doc/plans/2026-10-12-test-audit-controls.json new file mode 100644 index 00000000000..a6dfc4afeed --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-controls.json @@ -0,0 +1,539 @@ +{ + "date": "2026-10-12", + "policy": "Each mutation changes one actual production guard or outcome. Only the selected case runs. Every changed source is restored to its exact SHA-256 in finally. Complete unmutated owner and sibling controls run separately.", + "controls": [ + { + "id": "api-rejection-eviction", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/api/client.ts", + "sourceSha256": "1e1b3f03e3269a5943f55d294f1c7aa9f5c3eca569858d9fdc7c5d976de88bba", + "case": "tenant-session recovery preserves ApiError handling for an unrelated 401", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "Error: promise rejected \"ApiError: unauthorized { \u2026(2) }\" instead of resolving\n at Assertion.__VITEST_RESOLVES__ (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2434:33)\n at Assertion.propertyGetter (file:///node_modules/.pnpm/chai@6.3.0/node_modules/chai/index.js:1607:27)\n at Reflect.get ()\n at Object.proxyGetter [as get] (file:///Users/dotta/.codex/worktrees/tes" + }, + { + "id": "api-all-abort-eviction", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/api/client.ts", + "sourceSha256": "1e1b3f03e3269a5943f55d294f1c7aa9f5c3eca569858d9fdc7c5d976de88bba", + "case": "per-caller abort semantics aborts the shared fetch after all callers cancel and permits a fresh request", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected \"vi.fn()\" to be called 2 times, but got 1 times\n at /ui/src/api/client.test.ts:210:23\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "ticker-one-interval", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/hooks/useSecondTick.ts", + "sourceSha256": "fd51875adf5f30295d847df58af19f75dcbb59c810b4f02919427cbc6ea4d3b3", + "case": "useSecondTick shares a single timer across multiple active subscribers and stops when idle", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected 2 to be 1 // Object.is equality\n at /ui/src/hooks/useSecondTick.test.tsx:26:32\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules" + }, + { + "id": "ticker-delivery", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/hooks/useSecondTick.ts", + "sourceSha256": "fd51875adf5f30295d847df58af19f75dcbb59c810b4f02919427cbc6ea4d3b3", + "case": "useSecondTick shares a single timer across multiple active subscribers and stops when idle", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected '0' to be '1' // Object.is equality\n at /ui/src/hooks/useSecondTick.test.tsx:28:37\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_mod" + }, + { + "id": "ticker-idle-cleanup", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/hooks/useSecondTick.ts", + "sourceSha256": "fd51875adf5f30295d847df58af19f75dcbb59c810b4f02919427cbc6ea4d3b3", + "case": "useSecondTick shares a single timer across multiple active subscribers and stops when idle", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected 1 to be +0 // Object.is equality\n at /ui/src/hooks/useSecondTick.test.tsx:37:32\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_module" + }, + { + "id": "ticker-inactive-peer", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/hooks/useSecondTick.ts", + "sourceSha256": "fd51875adf5f30295d847df58af19f75dcbb59c810b4f02919427cbc6ea4d3b3", + "case": "useSecondTick does not subscribe or start a timer when inactive", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected '1' to be '0' // Object.is equality\n at /ui/src/hooks/useSecondTick.test.tsx:48:37\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_mod" + }, + { + "id": "ui-bound-caption", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "case": "AgentConfigForm environment selector reports the account-binding claim upward exactly once when the session authenticates", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected 'Sign in to OpenAISign inAuthenticated\u2026' to contain 'Agent bound to the signed-in account'\n at /ui/src/components/AgentConfigForm.render.test.tsx:1773:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "ui-onboarding-saved-login", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "case": "OnboardingWizard restore-gate (stale localStorage across accounts) the adapter step login panel (cheap auth signal, no adapter test) renders no 'Use saved login' control", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected 'Step 2 of 3Ada Connect a modelPapercl\u2026' not to contain 'Use saved login'\n at /ui/src/components/OnboardingWizard.test.tsx:2931:45\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "ui-agent-footer", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/components/IssueThreadInteractionCard.tsx", + "sourceSha256": "fb9beb27481258354942003e6a5c319a23257ef07f4ce811eb9ccbd605e0a585", + "case": "IssueThreadInteractionCard tool-action card marks agent resolution with an audit chip in the resolved footer", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: the given combination of arguments (undefined and string) is invalid for this assertion. You can use an array, a map, an object, a set, a string, or a weakset instead of a string\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2077:15)\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_j" + }, + { + "id": "ui-markdown-anchor", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "ui/src/components/MarkdownBody.tsx", + "sourceSha256": "003b7da23b5ebd0e8cd4de91e0d1dafe697f6602d4d99551b04d0abad699d193", + "case": "MarkdownBody opens external links in a new tab with safe rel attributes", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected '
/ui/src/components/MarkdownBody.test.tsx:587:18\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_" + }, + { + "id": "server-cancelled-reopen", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "server/src/routes/issues.ts", + "sourceSha256": "641b3d9cb5e744671527f16129dd78518376dca31616b845ec6b8da99d636d8c", + "case": "issue comment reopen routes rejects bare agent reopen requests on cancelled issues before writing a comment", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected 201 to be 409 // Object.is equality\n at /server/src/__tests__/issue-comment-reopen-routes.test.ts:1040:24\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "wiki-genuine-run-id", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "packages/plugins/plugin-llm-wiki/src/wiki/core.ts", + "sourceSha256": "35d8543f67ab80479c624f527d0b94fbb0f3b309d40b1276a07518872212ea06", + "case": "LLM Wiki plugin scaffold starts query sessions, records run ids, and forwards session events to plugin streams", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected [ \u2026(4) ] to deeply equal [ \u2026(4) ]\n at /packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts:3610:44\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "wiki-positive-source-writes", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "packages/plugins/plugin-llm-wiki/src/wiki/core.ts", + "sourceSha256": "35d8543f67ab80479c624f527d0b94fbb0f3b309d40b1276a07518872212ea06", + "case": "LLM Wiki plugin scaffold keeps default-space files at the root and isolates managed spaces under slug prefixes", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected [] to have a length of 2 but got +0\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2147:20)\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:1944:14)\n at Proxy." + }, + { + "id": "wiki-positive-page-writes", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "packages/plugins/plugin-llm-wiki/src/wiki/core.ts", + "sourceSha256": "35d8543f67ab80479c624f527d0b94fbb0f3b309d40b1276a07518872212ea06", + "case": "LLM Wiki plugin scaffold keeps default-space files at the root and isolates managed spaces under slug prefixes", + "expected": "one intended assertion failure", + "actualFailedCases": 1, + "intendedAssertionFailed": true, + "sourceRestored": true, + "failure": "AssertionError: expected [ { \u2026(2) }, { \u2026(2) } ] to have a length of 4 but got 2\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2147:20)\n at Proxy. (file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:194" + }, + { + "id": "aws-create-must-wait-before", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "server/src/services/secrets.ts", + "sourceSha256": "2b7cac8d903802ea47c71033fa2d5dffb4d01a9ccde80837445235b0208b8e97", + "case": "secretService serializes an aws_secrets_manager secret create against a concurrent account-home mutation lock holder", + "expected": "old weak control passes", + "actual": "passed", + "intendedResult": true, + "sourceRestored": true + }, + { + "id": "aws-rotate-must-wait-before", + "codeHead": "dfdc39b3a71c74c40e267010c6d8428af523bb65", + "owner": "server/src/services/secrets.ts", + "sourceSha256": "2b7cac8d903802ea47c71033fa2d5dffb4d01a9ccde80837445235b0208b8e97", + "case": "secretService serializes an aws_secrets_manager secret rotate against a concurrent account-home mutation lock holder", + "expected": "old weak control passes", + "actual": "passed", + "intendedResult": true, + "sourceRestored": true + }, + { + "id": "aws-create-must-wait-after", + "codeHead": "445d568ca4e903fc998819cae31d421403514684", + "owner": "server/src/services/secrets.ts", + "sourceSha256": "2b7cac8d903802ea47c71033fa2d5dffb4d01a9ccde80837445235b0208b8e97", + "case": "secretService serializes an aws_secrets_manager secret create against a concurrent account-home mutation lock holder", + "expected": "repaired control fails on provider entry before lock release", + "actual": "failed", + "intendedResult": true, + "sourceRestored": true, + "failure": "AssertionError: expected [ 'cleanup-enter', \u2026(1) ] to deeply equal [ 'cleanup-enter' ]\n at /server/src/__tests__/secrets-service.test.ts:1013:22\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + }, + { + "id": "aws-rotate-must-wait-after", + "codeHead": "445d568ca4e903fc998819cae31d421403514684", + "owner": "server/src/services/secrets.ts", + "sourceSha256": "2b7cac8d903802ea47c71033fa2d5dffb4d01a9ccde80837445235b0208b8e97", + "case": "secretService serializes an aws_secrets_manager secret rotate against a concurrent account-home mutation lock holder", + "expected": "repaired control fails on provider entry before lock release", + "actual": "failed", + "intendedResult": true, + "sourceRestored": true, + "failure": "AssertionError: expected [ 'cleanup-enter', \u2026(1) ] to deeply equal [ 'cleanup-enter' ]\n at /server/src/__tests__/secrets-service.test.ts:1081:22\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file:///node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + } + ], + "unmutatedControls": [ + { + "batch": "round4 owners and keepers", + "files": 58, + "passed": 1455, + "failed": 0, + "pending": 0 + }, + { + "batch": "tool-access owners and state keepers", + "passed": 623, + "failed": 0, + "pending": 0 + }, + { + "batch": "final state-service owners and keepers", + "files": 8, + "passed": 329, + "failed": 0, + "pending": 0 + } + ], + "preservationReviews": [ + { + "batch": "round4-ui", + "baseline": "2633dbbba95e853e3cb0de165bdf5116e544e411", + "method": "Independent whole-runtime AST comparison after actual new helper expansion and individually reviewed semantic edits. Type-only syntax and source metadata normalized; compiler is a separate gate.", + "files": [ + { + "file": "ui/src/components/IssueProperties.test.tsx", + "sourceSha256": "6c0a2420fdffeb3ccf6c9f4b43b61b62c3bd0e107276a91402a25ae08ad54bd2", + "proposalSha256": "8d604724e4c9c423b560d13dae6e939113f248b050905542970ee0660dfeab98", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "createRelation": 17 + } + }, + { + "file": "ui/src/components/AgentConfigForm.render.test.tsx", + "sourceSha256": "dd3703a491b43ee5b7cd57c48798e8a4cec4dbabc075f222cea412383eca49d7", + "proposalSha256": "57282ae3afe8c55ac118f3a658152d8df500a87b15383c8f6b0530a7fdcfb8a0", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "createFormQueryClient": 17, + "renderFormView": 18, + "daytonaEnvironments": 12, + "e2bEnvironments": 6 + } + }, + { + "file": "ui/src/components/OnboardingWizard.test.tsx", + "sourceSha256": "c17e65d7e0604b58d398cdf8a2a8588e7bc388ffe6e2612cee8e567e01a30f20", + "proposalSha256": "6215ee2f034686deaf2d6091590030dcdeccff1697422574e45e0351eaf5a7fe", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "renderWizardView": 19 + } + }, + { + "file": "ui/src/components/IssueThreadInteractionCard.test.tsx", + "sourceSha256": "3e3ebbe3da7ad7edd9386760bcf90a83c987956fc4f65b74bb761bd0160ce746", + "proposalSha256": "2e8aefda409a2faa7e3a3bc4d3f292a17086c61eac1e34291002b62c7415a184", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": {} + }, + { + "file": "ui/src/components/MarkdownBody.test.tsx", + "sourceSha256": "1be4fe615ed6d9e1b8b49749f291475c12848beb5664758c512f368103121608", + "proposalSha256": "7432e047a21ca0a75dc58ca0fd645c7cb0b59edc1719557e4e0cb0aafc8a642a", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": {} + } + ] + }, + { + "batch": "round4-server", + "baseline": "2633dbbba95e853e3cb0de165bdf5116e544e411", + "method": "Independent whole-runtime AST comparison after actual new helper expansion and individually reviewed semantic edits. Type-only syntax and source metadata normalized; compiler is a separate gate.", + "files": [ + { + "file": "server/src/__tests__/issue-comment-reopen-routes.test.ts", + "sourceSha256": "214aaa47ce1a862e4d06f41a440d0dba2a2f9f2efa63259c635209a3f660c33e", + "proposalSha256": "2dc9167f66f379cb5a1fe024faf78a79c7009d570c4ca4acc72448d0a00423c7", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "createScheduledRetry": 4, + "createAgentReviewPolicy": 12, + "createReviewIssue": 9 + } + }, + { + "file": "server/src/__tests__/environment-routes.test.ts", + "sourceSha256": "8993233f12a3b646f7a9755b817bac68916fda31753c2ffb30c4eba9fbcc9fe8", + "proposalSha256": "ef2dd81154c25791d2020362b6a3cd508ae215d60b075e1496ea9c7aa38ee6bd", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "localBoardActor": 39, + "createSecurePluginDriver": 6, + "createSshConfig": 4, + "createSecretBackedSshEnvironment": 3 + } + }, + { + "file": "server/src/__tests__/instance-settings-routes.test.ts", + "sourceSha256": "f007e394234f82d0014a1f2d83831942d0c3a2b123e026f4c22edd4d6491754f", + "proposalSha256": "85f0ddf9afb42cfc3e243e107b47290d9f8c8e5879eb3932805466019c937dc4", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "localAdminActor": 15 + } + }, + { + "file": "server/src/__tests__/agent-skills-routes.test.ts", + "sourceSha256": "e9c67d26d49c51862cefdde9388893d0e11b32f7d29ee95c664741cd48bc7468", + "proposalSha256": "175b4b5f274141a817c2135830c1b73ecdfb9597e1d0579af1d0207c6faf551e", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "skillSnapshot": 6 + } + }, + { + "file": "server/src/__tests__/company-skills-routes.test.ts", + "sourceSha256": "9fdfa5790bd33b9824e3aeed28daa01065eab248712d2f9482f633f7c33165a9", + "proposalSha256": "7037feadb73f235f81afeb9499b5bc9b988e2057b43add48c7546f6558085a03", + "fullProgramAstEqualAfterActualHelperExpansionAndEnumeratedBehaviorEdits": true, + "helperCalls": { + "createCatalogSkill": 3, + "createSkillTestRunDetails": 5, + "boardActor": 25, + "assignedAgentActor": 7 + } + } + ] + }, + { + "batch": "round4-cli-api-ticker", + "method": "Actual proposed helper bodies expanded. Source metadata/comments/types and object shorthand normalized; object fields, array rows, JSX props, all values/callbacks/promises/awaits, action/write/Git order and cleanup remain strict. Statement-helper parameters are checked to be stable Identifier bindings. Only enumerated reviewed retirement/migration deltas are permitted.", + "checks": [ + { + "file": "cli/src/__tests__/company.test.ts", + "sourceSha256": "d957294d6113f3b546e15e36289f5085852fc5ab1350529bc025a192fc05d378", + "proposalSha256": "1cd0c0445ab57a8c74416a63da6acb4ddde36e8c09aa5b3c74db589792445be1", + "actualHelperExpansionWholeRuntimeAstEqual": true, + "expandedCalls": { + "allImportEntities": 6, + "sourceCompanyManifest": 2, + "ceoManifest": 2, + "alphaProjectManifest": 2, + "kickoffIssueManifest": 2, + "openAiEnvInput": 2 + } + }, + { + "file": "packages/shared/src/app-definitions.test.ts", + "sourceSha256": "cf1aec35f1f4c65dd7059f594e04030c339c2a2f329f6e8216a431280d3865c3", + "proposalSha256": "d6aa2d7ba801b049358e89f48d39f3602ac936d1c28e7cd5b3313a2227fd1a95", + "actualHelperExpansionWholeRuntimeAstEqual": true, + "expandedCalls": {} + }, + { + "file": "packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts", + "sourceSha256": "21ae3eef99bb450f2cc0cac8cb7d781f0dbb043ad4fdf8c1d09b528021114242", + "proposalSha256": "bcb816d66be0b0176d599bbb705f79724abeebe8f52f28ed1fc31787b06f9985", + "actualHelperExpansionWholeRuntimeAstEqual": true, + "expandedCalls": { + "renderWikiPage": 22, + "captureWikiWrites": 9, + "managedSpaceRow": 3 + } + }, + { + "file": "cli/src/__tests__/worktree.test.ts", + "sourceSha256": "90162a4d544e80d7314649f2d0da5ca34c6968fbd4a63e491c57f5fdf7077db7", + "proposalSha256": "4dd193538aaf9bdd0cab522a8647ea5fb6a573e26199561e65c725c8bcb51ce1", + "actualHelperExpansionWholeRuntimeAstEqual": true, + "expandedCalls": { + "writeSeedConfigFiles": 6, + "initializeTestRepo": 5 + } + } + ], + "canonicalChecks": [ + { + "file": "cli/src/__tests__/worktree.test.ts", + "baselineSha256": "47c51bafca401b39bd976c12ddabef00be8ff6d3c2bc79822c49d11a3f79bc85", + "repairedSha256": "90162a4d544e80d7314649f2d0da5ca34c6968fbd4a63e491c57f5fdf7077db7", + "wholeRuntimeAstEqualApartFromParentCanonicalization": true, + "canonicalTempRootWrappers": 35 + }, + { + "file": "packages/shared/src/worktree-seed-source.test.ts", + "baselineSha256": "bd8708bab463280939f0aa8aa18341b028343fe98626d9be2154c87ab191c1a5", + "repairedSha256": "b9d021050cced44bcee00fd3209c225c4e26117693b7491a50ef9d3bd0a67d17", + "wholeRuntimeAstEqualApartFromParentCanonicalization": true, + "canonicalTempRootWrappers": 3 + } + ], + "coreChecks": [ + { + "file": "ui/src/api/client.ts", + "sourceSha256": "cac548df1d20c87a0ea8eede850cfe5081874262eb8c26c78058923db6947f69", + "proposalSha256": "1e1b3f03e3269a5943f55d294f1c7aa9f5c3eca569858d9fdc7c5d976de88bba", + "onlyDeletedPrivateGetterExport": "__inflightGetCount" + }, + { + "file": "ui/src/hooks/useSecondTick.ts", + "sourceSha256": "e108dee2647be4f8f5472a70220d1019b18cce9a842e7bc25b863e298654ab07", + "proposalSha256": "fd51875adf5f30295d847df58af19f75dcbb59c810b4f02919427cbc6ea4d3b3", + "onlyDeletedPrivateGetterExport": "__secondTickInternals" + } + ], + "seamTestChecks": [ + { + "file": "ui/src/api/client.test.ts", + "sourceSha256": "b056adfc0a935cdabcc1c05b4473f76bc1f46fc6f280a671404c2735211ebe01", + "proposalSha256": "1b3666404821ae4550b1fdf48fe27568077f8074dbdff8afc5f2966e79341ffd", + "apiWholeRuntimeAstEqualApartFromReviewedPrivateRemovalAndErrorAbortRetry": true, + "oldDeclarations": 12, + "newDeclarations": 12 + }, + { + "file": "ui/src/hooks/useSecondTick.test.tsx", + "sourceSha256": "29d8f37a65070f28ddb43bec69e74477cdfb1e563b348aff03b6b5ca17902e95", + "proposalSha256": "b59f9bab5e41bc611165577d8e095184094dd5b84b6d436d32656fd33fb5e459", + "oldDeclarations": 2, + "newDeclarations": 2 + } + ] + }, + { + "batch": "round5-tools", + "baseline": "3e7ee04591fd82f3effb89370f47aa90dcadd6eb", + "method": "Only source metadata/comments and object shorthand differ. Actual candidate helper bodies expanded, removing only their new declarations and type-only IdRef aliases. Object-field, nested object, array, table, JSX-free input, promise, await, callback, actor/company/credential/expected value and statement order remain exact. Newly introduced literal spreads are allowed only when there are no overwritten keys or effectful helper arguments. All helper object bodies are fresh literals without external closure state.", + "files": [ + { + "path": "server/src/__tests__/tool-access-service.test.ts", + "sourceSha256": "4cd7d6d02710eac514f14d87c316d15391028ae36c477d868f6b5a499a29f406", + "proposalSha256": "dc3664a18f9fc6c45b3b5555376ae03def2708129ff5c3dc4f4623ea2f994c15", + "completeRuntimeAndTypeAstEqualAfterActualHelperExpansionAndOneDeclarationRemoval": true, + "helperOccurrences": 78, + "allRemainingMatcherCallsEqual": true, + "allAwaitExpressionsEqual": true, + "allSupertestCallsEqual": true, + "remainingMatcherCalls": 1346, + "awaitExpressions": 1930, + "beforeDeclarations": 266, + "afterDeclarations": 265 + }, + { + "path": "server/src/__tests__/tool-access-policy-service.test.ts", + "sourceSha256": "484408a4eb3481e98d89863943b0fac64cf40e3794c06d0b4326a4b0d0f7625f", + "proposalSha256": "6982ad8f42fceeb6428117f22db28a589f9b6d5f1b2465ca87ddd29420e06413", + "completeRuntimeAndTypeAstEqualAfterActualHelperExpansionAndOneDeclarationRemoval": true, + "helperOccurrences": 31, + "allRemainingMatcherCallsEqual": true, + "allAwaitExpressionsEqual": true, + "allSupertestCallsEqual": true, + "remainingMatcherCalls": 80, + "awaitExpressions": 284, + "beforeDeclarations": 35, + "afterDeclarations": 35 + }, + { + "path": "server/src/__tests__/tool-gateway-service.test.ts", + "sourceSha256": "29c9b9739a5f928c49cd2982858a1323c242663450277f45fc9e3aea407cbe98", + "proposalSha256": "67d8dfb12ef2215296af87be5c573f40175d71a2917b01113aa82c550fe4b6d6", + "completeRuntimeAndTypeAstEqualAfterActualHelperExpansionAndOneDeclarationRemoval": true, + "helperOccurrences": 58, + "allRemainingMatcherCallsEqual": true, + "allAwaitExpressionsEqual": true, + "allSupertestCallsEqual": true, + "remainingMatcherCalls": 231, + "awaitExpressions": 431, + "beforeDeclarations": 36, + "afterDeclarations": 36 + } + ] + }, + { + "batch": "round5-state", + "baseline": "3e7ee04591fd82f3effb89370f47aa90dcadd6eb", + "method": "Independent full runtime AST expansion outside two individually reviewed AWS lock repairs", + "files": [ + { + "file": "server/src/__tests__/issue-thread-interactions-service.test.ts", + "beforeSha256": "ddfad204b976a8218b6376007c36048154c4a10e25ef4d891531b87477931c10", + "afterSha256": "6c70d8af0815de3d3ed9f87de94d20e7a161aa2db5fe938e80204a100ee418e9", + "equivalent": true, + "expandedBeforeSha256": "0a7affd96c9e84850e2540edea63bbc6eabf24f124468e2526fa3095f2ddd7af", + "expandedAfterSha256": "0a7affd96c9e84850e2540edea63bbc6eabf24f124468e2526fa3095f2ddd7af", + "allHelperCalls": { + "interactionCompanyRow": 14, + "parentIssueRow": 7, + "activeCodexAgentRow": 19, + "acceptIssueScope": 31 + }, + "allHelperArgumentsEffectFree": true, + "beforeDeclarations": 89, + "afterDeclarations": 89 + }, + { + "file": "server/src/__tests__/secrets-service.test.ts", + "beforeSha256": "ab9c9536ee144d9211bb8a205dd2e57ff928360de6ea695af74afd749e9652be", + "afterSha256": "95708c40da78768c43c26a56c73cf9479a4a39bf2173561d9501a1ab6d13dfb1", + "equivalent": true, + "expandedBeforeSha256": "fb5da5592c1de24b62a44e476554924e0854cd2822767e46132ce63431c11475", + "expandedAfterSha256": "fb5da5592c1de24b62a44e476554924e0854cd2822767e46132ce63431c11475", + "allHelperCalls": { + "awsVaultInput": 29, + "preparedAwsVersion": 16, + "managedAwsVersionRow": 4 + }, + "allHelperArgumentsEffectFree": true, + "beforeDeclarations": 91, + "afterDeclarations": 91 + } + ] + } + ] +} diff --git a/doc/plans/2026-10-12-test-audit-next.md b/doc/plans/2026-10-12-test-audit-next.md new file mode 100644 index 00000000000..ff2b377c51c --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-next.md @@ -0,0 +1,121 @@ +# Test audit: consolidate repeated owner fixtures and rendering + +Date: 2026-10-12 +Branch: `codex/test-audit-next` +Discovery baseline: `b4aee6ea18905690686fb318fa9cc6d4cc71a7e1` +Integration base: `1e0cfcb4fc4ed78d10f46cd60990935af4ac9973` (merged master) +Verified code commit: `4261a7fc1b964e54059c68246a0a4b7ea74bdcea` +Parent cleanup: [PR #15978](https://github.com/paperclipai/paperclip/pull/15978) + +This follow-up removes **2,041 net test lines across five files**. Every +registered case and assertion remains. It changes no application source. +The seven complete owner/sibling files pass all **990 cases** before and after. +Workspace typecheck, build, token gates and diff checks pass. The change +consolidates fixture setup while preserving execution count. + +The parent PR merged on 2026-10-12 after all remote checks passed and a +fresh Greptile 5/5 review. This branch used a separate managed checkout and +dependency installation. Only its new follow-up commit was replayed onto +the parent merge, with no conflicts or duplicated cleanup commits. All five +test files still exactly match the independently reviewed candidates, and +all 990 owner/sibling cases pass again on the merged base. The follow-up remains +committed locally on its own branch. Earlier cleanup evidence, including its +disclosed local timeout and passing owner reproduction, lives in +`2026-10-12-test-audit.md`. + +## Measured change + +| File | Before lines | After lines | Net removed | Cases retained | +| --- | ---: | ---: | ---: | ---: | +| server/src/__tests__/environment-runtime.test.ts | 8,110 | 7,223 | 887 | 162 | +| server/src/__tests__/execution-workspaces-service.test.ts | 4,751 | 4,638 | 113 | 67 | +| server/src/__tests__/company-portability.test.ts | 6,194 | 6,100 | 94 | 94 | +| ui/src/pages/IssueDetail.test.tsx | 6,652 | 6,338 | 314 | 141 | +| ui/src/components/IssueChatThread.test.tsx | 5,032 | 4,399 | 633 | 100 | +| **Changed owners** | **30,739** | **28,698** | **2,041** | **564** | + +The remaining 426 validation cases are unchanged TaskChatThread (225) and +AppsConnect (201) siblings. Figures exclude this report. No case, timeout, +skip condition, filename, CI route or production seam is removed. + +## Owner and keeper decisions + +All nominated cases are retained. Each original case remains the primary +keeper of its regression assertions; helpers own only repeated inputs. + +- Environment runtime: 71 repeated plugin/lease/worker/foreign-company + expressions become synchronous file-local fixture builders. Complete + identity, author, schema, readiness, capability, install-order, stale lease, + fingerprint and RPC-budget variants remain defaults or explicit overrides. + Preserve real persisted rows and the actual foreign-company rejection, + orphan teardown/spool recovery and lease-reuse paths. Dynamic/spread rows + and the named-constant secret provider stay inline. +- Execution workspaces: 19 repeated task/recovery/branch/runtime/agent inputs + become synchronous builders. Real Git operations, active claimant/runtime + rows, locked races, clean/dirty divergence and durable recovery assertions + remain in their original cases. +- Company portability: 15 identical export invocations use one local helper; + their distinct fixture data, redaction, permission, import, selected-file, + logo and membership expectations remain explicit. +- IssueDetail: 98 identical root/provider render frames share one synchronous + helper. It retains the same QueryClient and root. Four nondefault children + remain explicit JSX; unsafe undefined/key/ref/spread shapes stay inline. +- IssueChatThread: 70 MemoryRouter/component frames share one synchronous + helper with fresh original defaults. Supplied callback references, props, + dynamic expression order, root identity and rerender/remount behavior remain. + +TaskChatThread and AppsConnect already have useful fixtures or distinct +transport/identity behavior. A TaskChatThread prototype saved only ten lines +after helper overhead and was rejected. No further layer retirement was +supported in these owners; separate provider, authentication, lifecycle and +storage proof remains valuable. + +## Preservation evidence + +Original/candidate SHA-256 guards were validated before any checkout edit. +Proposals were prepared outside the checkout during baseline execution. +Independent reviewers checked both groups against the committed baseline. + +Server: expand all 105 calls through the 16 actual helper bodies/defaults and +compare all three complete programs. Field order, nested schemas/capabilities, +types/casts, table rows, callbacks, hooks, 1,347 awaits and 1,201 expectation +calls are unchanged. Helpers add no Promise boundary, transaction, UUID, +clock read, receipt, assertion, RPC or reset. Objects and mocks remain fresh. + +UI: independently expand all 98 + 70 actual render calls. The complete program +outside those checked expressions is unchanged, including types, callbacks, +actions, waits, timers, teardown and budgets. All 778 expectation calls and +200 declarations remain. Check every thread root identity and dynamic prop +evaluation order; check IssueDetail root/client bindings for shadowing. +Only unique JSX attribute order is normalized; key/ref/spread shapes are +excluded. No React component wrapper or hook is introduced. + +Both independent reviews found no preservation blocker. No assertion was +moved or repaired, so no additional production mutation was needed. The +earlier parent PR's mutation proof is recorded in its report. + +## Runtime verification and limits + +- Complete baseline: seven files, 990 passed, zero failed/skipped. +- Complete candidate: the same seven files, 990 passed, zero failed/skipped. +- Rebased candidate: the same seven files, 990 passed, zero failed/skipped. +- `pnpm --filter @paperclipai/ui typecheck`: pass before integration. +- `pnpm -r typecheck`: pass before integration and at the verified code commit. +- `pnpm build`: pass at the verified code commit. +- Token gates, palette synchronization and character synchronization: pass. +- `git diff --check`: pass. +- The new checkout used its own workspace-linked dependencies and built its + own TypeScript runner/shared/plugin SDK prerequisites. No dependencies or + source files were linked to the concurrently changing PR checkout. +- A complete default `pnpm test:run` has not been repeated for this follow-up. + Its test-only fixture changes have complete affected-owner/sibling proof. + Browser suites were not run; none of those suites changed. The parent PR + has its own full CI evidence. No wall-time speedup is attributed to this work. + +## Additional retirement search + +A bounded independent search found no further defensible retirement of +200 or more lines. Provider-specific behavior, separately implemented +parsers, CSS contrast/theme/layout contracts and runtime/SDK wiring retain +independent proof. Similar source guards or DOM class assertions did not +provide complete behavioral keepers, so those layers remain. diff --git a/doc/plans/2026-10-12-test-audit-rounds.md b/doc/plans/2026-10-12-test-audit-rounds.md new file mode 100644 index 00000000000..ef3c12da564 --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-rounds.md @@ -0,0 +1,92 @@ +# Test audit: repeated setup, duplicate cases and false proof + +Date: 2026-10-12. Audit branch: `codex/test-audit-next`. Delivery branch: `codex/test-audit-followup`. Verified code revision: `64370831ebe191e5ce092f85f79b6fb661df7e16`, including master `cc88b61c0918f45f6d3215f5d2d2368c88297046`. + +This follow-up removes **5,027 net test lines across 27 test files** and **15 redundant registered executions**. It separately removes **11 unused application lines** from two private test-inspection exports. Most savings come from sharing repeated setup while retaining every distinct case. No wall-time improvement is inferred from line counts. + +The earlier merged [PR #15978](https://github.com/paperclipai/paperclip/pull/15978) removed 11,976 net test/support lines and 1,073 redundant executions. The two changes together save **17,003 test/support lines**. These counts exclude audit reports, upstream changes and the fixture portability repair. + +## Savings and scope + +| Batch | Net test lines removed | Registered executions removed | Preservation | +| --- | ---: | ---: | --- | +| Earlier local runtime/workspace/portability and IssueDetail/IssueChatThread | 2,041 | 0 | All original cases and assertions retained; 990 owner/sibling cases passed. See [the earlier follow-up report](2026-10-12-test-audit-next.md). | +| Route fixtures and duplicate proof | 970 | 5 | Three redundant cases and two identical table executions removed; cancelled-reopen observation repaired. | +| UI fixtures and duplicate proof | 521 | 6 | Exact assertions move into named stronger keepers; two weak observations repaired. | +| CLI, shared catalog and wiki plugin | 238 | 3 | Exact keepers and fixture expansion; genuine run-receipt and positive-write witnesses added. | +| Public API and ticker observations | -10 | 0 | Tests grow by ten lines to replace private-state inspections with observable results. | +| Tool access, policy and gateway | 656 | 1 | One exact omitted-scope duplicate removed; all other matcher and await order retained. | +| Interactions and secrets state services | 611 | 0 | All 180 declarations / 197 cases retained; two AWS lock observations repaired. | +| **Total follow-up** | **5,027** | **15** | **No independent security or lifecycle contract retired.** | + +Line savings include all newly added local helper code. The application reduction is five lines of `__inflightGetCount` and six lines of `__secondTickInternals`. Repository-wide caller searches found only their tests. Both module cores otherwise remain byte-identical. Their tests retain sharing, cancellation, stale-account isolation, rendered ticks and timer lifecycle. + +The final intended diff has 27 test files, those two application files and audit evidence. No schema, telemetry, runtime service, package manifest, lockfile or CI routing change is part of this cleanup. Incoming master changes were merged, so absent upstream files cannot inflate the savings. + +## Removed execution to keeper map + +Each retained declaration is its own keeper for setup-only consolidation. The following is the complete map for the 15 removed executions; the three-row influence table accounts for two of them. + +| Removed proof | Retained keeper and boundary | +| --- | --- | +| Managed-sandbox duplicate DELETE rejection | Same owner/admin, platform row, DELETE, 403 and code; stronger keeper also forbids blast-radius/removal calls. | +| Agent-skills duplicate GET/POST secret-context check | Mandatory resolver callbacks in both successful route keepers check the same actor/user/source/responsible literal context, adapter type, skip-user-secrets and filtered environment. | +| Company-skills open-default legacy-consent duplicate | Same actor/company/run, deny-missing-consent decision and POST input; successful write keeper checks no consent-gate invocation and the actual write. | +| Two extra missing/wrong-run influence table rows | All three ran the identical zero-argument callback. One retains both POST/PATCH errors, details, two propagation calls and no writes. The identity-variant service sibling and real PostgreSQL race sibling remain. | +| Onboarding missing-auth/no-stored-login duplicate | Warning text moves into `blocks the hire on a warn result that holds adapter_auth_missing, and shows the returned checks`. | +| Interaction connection controls for another viewer | `shows another viewer only who it is waiting for` retains waiting copy, zero buttons, no actions/auth URL and absence of Not now. | +| Legitimate question-card rendering duplicate | `exposes pending question options as selectable radio and checkbox controls` gains positive child count and retains stronger exact radio counts. | +| Addressed-user Connect/Not-now duplicate | `opens the shared connection setup for the addressed user` gains the exact copy and absence checks before executing the real setup action. | +| Non-GitHub-link icon duplicate | `opens external links in a new tab with safe rel attributes` uses the same docs input and retains exact anchor, no GitHub icon, target and rel. | +| External-link trailing-icon duplicate | The same docs-link keeper retains the icon glued to the final character and the original glyph matcher. | +| Hugging Face scope duplicate | `requests Airtable record, schema and comment writes, and Hugging Face repository/job actions` retains the same independent `[read-mcp, read-repos, contribute-repos, jobs]` literal. | +| Whole-catalog method/field invariants duplicate | Full-catalog `appDefinitionsSchema.parse` enforces nonempty ownership modes, required credential placeholders and API-key key placement for every definition; provider-specific permission literals remain. | +| Worktree shutdown formatter duplicate | Real `ensure-seeded` failure retains restore error, safe diagnostic, failed manifest and lock release. The unique migrations fallback expectation moves unchanged into that keeper. | +| Tool-access omitted-provider-scope duplicate | `marks a scope inferred from the request when the provider omits one` calls the identical `connectSlackWithTokenScope(null, ["channels:read"])`, retains all three persisted scope fields and also checks the outgoing authorization scope. Reported over-grants remain separately tested. | + +The influence identity sibling uses a fake `counterDb` adapter for locked-row variants. The PostgreSQL sibling independently inserts real rows and races writes. Neither is removed, and fake SQL inputs are not described as durable persistence proof. + +## Preservation review and repairs + +Every nominated owner was read in full, including hooks, declarations, tables and cleanup, before classification as retain, fix false proof, consolidate or delete. Reviews checked actual owner/caller/history/CI boundaries rather than treating similar names as proof. Separate reviewers expanded the actual new helper bodies and arguments against complete original runtime ASTs. Types and source metadata are normalized separately from the compiler gate. Fields, nested values, fresh allocations, action/query/UUID/Date/callback/await order, tables, timeouts and teardown remain strict outside explicitly reviewed semantic edits. + +The tool-access expansion covers 167 helper occurrences, 1,657 remaining matcher calls and 2,645 awaits. The state-service expansion covers 120 helper calls; no DB query is hidden in a new helper, and no new helper manufactures a tested service result. All 106 interaction and 91 secret cases remain. Two AWS repairs add two provider-call assertions while retaining the original durable results. + +Review restored or strengthened these contracts before cutover: + +- Restore the no-agentMap resolved footer/chip case byte-for-byte; a richer mapped-agent case cannot prove its fallback. +- Match the exact external ` + +[Contract and maintenance](README.md) · [Review findings](REVIEW.md) + +224 story files; 2223 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. + +## access + +[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. | + +## activity + +[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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | + +## 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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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 | 20 / 1 | Production configuration panels in a locally composed settings shell; full AgentDetail route parity remains unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [Agents and reusable capabilities/Hiring, configuration, and organization/Codex runner/Creation](../../ui/storybook/stories/agents/codex-runner-creation.stories.tsx) | `agents/hire`, `runs-adapters/availability`, `runs-adapters/validation` | unreviewed / production / fixtures | 8 / 6 | Production NewAgent and AgentBasicsDialog with an isolated fetch boundary. Native/Legacy replies and save mutations are simulated; source inspection does not establish all play cases or provider/default qualification. Existing eight story IDs retained through explicit metadata and feature placement. Native adapter inventory is not duplicated when the shared preview provides it. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | + +## 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. | +| [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. | +| [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. | +| [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. | +| [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. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. | +| [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. | +| [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. | +| [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. | +| [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. | +| [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. | +| [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. | +| [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. | + +## companies + +[Feature recipe](../feature-map/companies.md) + +| 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. | + +## connection-setup + +[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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. | + +## developer-labs + +[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. | + +## documents-artifacts + +[Feature recipe](../feature-map/documents-artifacts.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Commit](../../ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx) | `documents-artifacts/work-products` | 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/Documents, attachments, and work products/Explorations/Artifact cards/Data](../../ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx) | `documents-artifacts/work-products` | 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/Documents, attachments, and work products/Explorations/Artifact cards/Document](../../ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx) | `documents-artifacts/work-products` | 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/Documents, attachments, and work products/Explorations/Artifact cards/File](../../ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx) | `documents-artifacts/work-products` | 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/Documents, attachments, and work products/Explorations/Artifact cards/Image](../../ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx) | `documents-artifacts/work-products` | 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/Documents, attachments, and work products/Explorations/Artifact cards/Link preview](../../ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx) | `documents-artifacts/work-products` | 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/Documents, attachments, and work products/Explorations/Artifact cards/Pull request](../../ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx) | `documents-artifacts/work-products` | 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/Documents, attachments, and work products/Explorations/Artifact cards/Video](../../ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx) | `documents-artifacts/work-products` | 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/Documents, attachments, and work products/Artifact upload/01 Artifacts pane](../../ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx) | `documents-artifacts/attachments` | unreviewed / production / fixtures | 10 / 3 | 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/Artifact upload/02 Paused task journey](../../ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx) | `documents-artifacts/attachments` | unreviewed / production / fixtures | 6 / 1 | 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/Pages/Artifacts](../../ui/storybook/stories/documents-artifacts/artifacts.stories.tsx) | `documents-artifacts/library`, `documents-artifacts/work-products` | 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/Documents, attachments, and work products/Components/CSV File Tab](../../ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx) | `documents-artifacts/attachments` | 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/Documents, attachments, and work products/Product/Documents/Annotations](../../ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx) | `documents-artifacts/annotations` | 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/Documents, attachments, and work products/Product/Document Comments](../../ui/storybook/stories/documents-artifacts/document-comments.stories.tsx) | `documents-artifacts/annotations` | 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/Documents, attachments, and work products/Components/File Viewer Sheet](../../ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx) | `documents-artifacts/attachments` | 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/Documents, attachments, and work products/HTML artifacts/01 Preview](../../ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | 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/Documents, attachments, and work products/HTML artifacts/02 Workspace](../../ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 3 / 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/HTML artifacts/03 Task journey](../../ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx) | `documents-artifacts/attachments`, `workspaces/files-git` | unreviewed / production / fixtures | 3 / 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/Task Chat/Rich Work Product Cards](../../ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx) | `documents-artifacts/work-products` | unreviewed / production / fixtures | 9 / 3 | 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/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 + +| 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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. | + +## gateways-profiles + +[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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | + +## 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. | +| [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. | + +## 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 + +[Feature recipe](../feature-map/runs-adapters.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections](../../ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx) | `runs-adapters/validation`, `agents/hire` | current / production / fixtures | 7 / 6 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Usage limits](../../ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx) | `runs-adapters/validation` | unreviewed / production / fixtures | 8 / 8 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Review](../../ui/storybook/stories/runs-adapters/ai-connections.stories.tsx) | `runs-adapters/validation` | unreviewed / production / fixtures | 46 / 15 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [Agents and reusable capabilities/Runs, harnesses, and model accounts/Connectors/Pool host](../../ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx) | `runs-adapters/validation`, `connection-setup/catalog` | unreviewed / production / fixtures | 5 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Connection pools](../../ui/storybook/stories/runs-adapters/connection-pools.stories.tsx) | `runs-adapters/validation` | unreviewed / production / fixtures | 16 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [Agents and reusable capabilities/Runs, harnesses, and model accounts/Assistant connections/Dot Runner](../../ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx) | `runs-adapters/validation`, `gateways-profiles/client-connect` | unreviewed / production / fixtures | 4 / 2 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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) | `runs-adapters/validation` | unreviewed / production / fixtures | 4 / 3 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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) | `runs-adapters/validation` | unreviewed / guide / static | 1 / 0 | Review guide retained; explanatory content does not establish runtime or production coverage. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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) | `runs-adapters/validation` | mixed / mixed / fixtures | 6 / 3 | Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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) | `runs-adapters/validation` | mixed / mixed / fixtures | 14 / 3 | Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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) | `runs-adapters/validation` | mixed / mixed / fixtures | 34 / 7 | Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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) | `runs-adapters/validation` | mixed / mixed / fixtures | 11 / 5 | Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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) | `runs-adapters/validation` | mixed / mixed / fixtures | 7 / 2 | Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | + +## secrets + +[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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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 + +[Feature recipe](../feature-map/skills.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Agents and reusable capabilities/Skills and Skill Studio/Agent Skills/AgentSkillRow](../../ui/storybook/stories/skills/agent-skill-row.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/QA / Agent skills edge states](../../ui/storybook/stories/skills/agent-skills-edge.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/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 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/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/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 + +[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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update. | +| [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 + +[Feature recipe](../feature-map/teams.md) + +| 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. | + +## workspaces + +[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. | +| [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. | +| [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. | +| [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. | +| [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. | +| [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. | +| [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. | + +## 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 and reusable capabilities/Hiring, configuration, and organization/Codex runner/Creation](../../ui/storybook/stories/agents/codex-runner-creation.stories.tsx) (unreviewed) +- `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) +- `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/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. +- `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/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/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), [Agents and reusable capabilities/Hiring, configuration, and organization/Codex runner/Creation](../../ui/storybook/stories/agents/codex-runner-creation.stories.tsx) (unreviewed) +- `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), [Agents and reusable capabilities/Hiring, configuration, and organization/Codex runner/Creation](../../ui/storybook/stories/agents/codex-runner-creation.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) +- `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. +- `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. diff --git a/maps/storybook-map/README.md b/maps/storybook-map/README.md new file mode 100644 index 00000000000..1351a22b78f --- /dev/null +++ b/maps/storybook-map/README.md @@ -0,0 +1,91 @@ +# Storybook map + +The [feature index](INDEX.md) connects the existing Storybook to stable capability +IDs from the upstream [feature map](../feature-map/README.md). Story files remain +in `ui/storybook/`. The feature map and its maintenance skill do not depend on +this catalog. New capabilities or upstream recipe drift are reported to their +maintainer; this catalog does not redefine product behavior or permissions. + +## Ownership + +- [maintain-storybook](../../.agents/skills/maintain-storybook/SKILL.md) owns story + alignment, fixture corrections, classification, and this catalog. +- [review-storybook](../../.agents/skills/review-storybook/SKILL.md) consumes it to + select and exercise examples, then reports evidence and drift. +- [DESIGN.md](../../DESIGN.md) governs design. Production verification remains + with the existing product recipes and verification tools. + +## 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, +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. +Foundations/guides and proposals without a mapped capability need an exception +reason. An absent story is not automatically a feature gap. + +## Folders and sidebar + +Story files live in `ui/storybook/stories//`, using the first +capability's area slug. Related capabilities still point to the same story. Shared +fixture modules and assets live in `ui/storybook/support/`; production wrappers in +`ui/storybook/prototypes/` retain their existing ownership. + +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. + +Each suite declares an explicit CSF `id`, recorded as `stableId` in the catalog. +The `sidebarTitle` can change without changing that identity. Existing story and +autodocs URLs and screenshot names remain valid. Do not derive a replacement ID +from a newly organized title. The checker rejects missing or changed explicit IDs, +incorrect feature folders, and sidebar titles outside their primary feature home. + +Each entry records purpose, capability references, review sources/date/fingerprint, +notes, and three independent dimensions: + +| Dimension | Values | +| --- | --- | +| Product status | `current`: source comparison supports the named subject; `proposed`: intentional alternate design; `historical`: superseded reference retained for its original purpose; `mixed`: suite combines these; `unreviewed`: parity has not been established | +| Implementation | `production`: shipped subject with fixture/layout wrappers; `prototype`: locally implemented subject, even if it uses shared primitives; `mixed`: both subjects; `guide`: explanatory documentation | +| Data boundary | `fixtures`: props, callbacks, caches or API responses are simulated; `fixture-server`: requires a separately owned local API/database fixture with simulated upstream providers; `static`: explanatory/static examples | + +`current` is a source-review classification, not a promise that every state renders +or that APIs, gates, persistence and providers passed live tests. `unreviewed` +entries still have an inventory/classification review; notes explain the deeper +parity work outstanding. Mixed files retain a note naming the important split. +Production components can legitimately use mock data. A prototype directory name +does not establish implementation: some wrappers there re-export shipped UI. + +Review sources include the story, relevant local support and production subjects. +Include directly rendered subjects regardless of import spelling, and primitives +when the suite demonstrates them. Do not truncate the list to a fixed source count. +Fingerprints make changes to those declared dependencies visible. They do not +follow every transitive production import or infer semantic correctness. Shared +preview/config/fixture changes have a separate fingerprint and require reviewing +affected callers. Update fingerprints only after reviewing the change, and add +missing dependencies when discovered. Date/revision context for the initial pass +and executed evidence belong in [REVIEW.md](REVIEW.md). + +## Maintenance commands + +After installing workspace dependencies: + +```sh +node scripts/storybook-catalog.mjs +node scripts/storybook-catalog.mjs --write +node --test scripts/storybook-catalog.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 +fingerprints or blesses drift. Preserve IDs when changing titles or moving files; +compare the existing built Storybook index before and after such changes. + +Use the existing [Storybook workflow](../../doc/DEVELOPING.md#storybook) for builds, +browser review, and visual tests. Report source inspection, rendering, `play` +assertions, visual comparisons and production acceptance separately. Do not publish +new visual baselines just to make catalog maintenance pass. diff --git a/maps/storybook-map/REVIEW.md b/maps/storybook-map/REVIEW.md new file mode 100644 index 00000000000..a1d07e371ff --- /dev/null +++ b/maps/storybook-map/REVIEW.md @@ -0,0 +1,235 @@ +# Initial Storybook review — 2026-10-12 + +Source baseline: `f0e12c851971a21f7aadf0835b016bfaf2067314`. +Scope: complete source inventory and initial classification, with targeted source +comparison and browser checks. This is not full state parity, full interaction +coverage, or production acceptance for all stories. + +## Inventory and organization + +There is one Storybook, already centralized under `ui/storybook/`. At the initial +baseline, its 223 story files were split between 176 files at the stories root and +47 nested files, with 51 top-level sidebar groups. The initial pass recorded +product meaning, provenance, and review status before changing that organization. +The follow-up organization pass below moves the files and sidebar together. + +The downstream catalog gives each file a primary feature home and related stable +capability IDs. The generated index includes all 171 upstream capabilities across +35 feature areas. The existing Overview story now provides searchable feature +navigation into the actual built story index. The initial pass kept sidebar titles +stable; the follow-up uses explicit IDs to preserve links while changing titles. + +| Dimension | Initial classification | +| --- | --- | +| Product alignment | 18 current subjects, 4 proposals, 5 historical suites, 8 mixed suites, 188 requiring deeper parity review | +| Rendered subject | 199 production, 8 prototype, 11 mixed, 5 guides | +| Data boundary | 217 fixture-based, 1 separate fixture server, 5 static guides | + +A production subject can have unreviewed state parity. A current subject can still +have untested loading, error, permission, or backend behavior. The catalog's notes +and source references qualify these labels; none are a substitute for live proof. + +## Corrections and retained history + +- **Question dismissal and send:** the local interaction-above-composer harness + kept a pending badge for dismissed questions and left the question open after + a normal send. Production `TaskChatThread` excludes questions from + `pendingReminderInputs` and dismisses the selected question on send. The harness + now matches those rules. Added question-dismiss and confirmation-reopen cases; + corrected the send case's stale editor selector. The story still does not prove + persisted dismissal or history behavior. +- **Fast response helpers:** `empty`, `configured`, and `result` were exported + fixtures which Storybook also treated as stories. Their IDs collided with + `Empty`, `Configured`, and `Result`. Explicit exclusion removes the false exports + while retaining the real stories and imported fixtures. Browser review also + exposed a missing explicit `onConnectionChange` spy after the production view + began calling it on mount; added that fixture callback. +- **Chat provenance:** the main chat wrapper combines production conversation/task + pages with a local landing view. The separate first-created-agent chat-entry + design is historical; current primary-agent examples are its replacement. +- **Historical designs retained:** GitHub chat, old agent onboarding, connect-model + tiles, paused composer, and chat-entry remain available with their purpose and + replacement references. Local proposals are labeled rather than deleted. +- **Prototype folder is not a verdict:** some files re-export production subjects; + others implement local compositions. Static import traversal found no obvious + orphan prototype TS/TSX module. This is not proof that each is still useful. +- **Contributor guidance:** replaced the old Overview's manual coverage claims, + corrected the interaction-authoring skill's obsolete story path, and updated the + prototype README's catalog source count to match the shipped five sources. + +## Verification + +Environment: local macOS, headless Chromium, built Storybook 10.6.0, fixture data. +The following nine `play` cases reached Storybook's `storyFinished: success` event +without a browser page exception. These results establish story interactions only. + +| Story ID | Subject / assertion | +| --- | --- | +| `composer-interaction-above-composer--ask-user-questions-and-send` | Production card/composer in local harness; normal send dismisses question without a pending badge | +| `composer-interaction-above-composer--dismiss-question-without-pending-badge` | Production card/composer; dismiss leaves editable composer without pending badge | +| `composer-interaction-above-composer--dismiss-confirmation-keeps-pending-badge` | Production card/composer; dismiss retains confirmation badge and reopening works | +| `composer-new-task--empty` | Production composer wrapper; existing play assertions | +| `design-explorations-agent-chat-sidebar-components--add-and-reopen-chat` | Production sidebar; fixture add/reopen assertions | +| `assistant-connections-consent--choose-organization` | Production consent surface; fixture organization selection assertions | +| `historical-github-chat-prototypes--standard-trust-warning` | Historical prototype; existing trust-warning assertions, not current GitHub acceptance | +| `design-previews-recovery-notice--retry-request-failed` | Production recovery notice; fixture failure-state assertions | +| `composer-mobile-assignee-picker--picker-open` | Production picker; existing open-picker assertions | + +Additional manual browser checks passed: search the guide for `steering/queue`, +follow its link to the real queued-message story, dismiss a question, then reload +and verify that the fixture resets to an open question. Screenshots were inspected +locally and are not committed as new regression baselines. + +Built-index comparison: all 2,208 prior story IDs remain; only the two new dismissal +regressions were added (2,210 total). Catalog validation, eight checker tests, +feature-map validation, both skill validators, token gates, workspace typecheck, +and workspace build passed. Fast-response settings `empty`, `configured`, and +`result` subsequently reached `storyFinished: success` with no page exceptions +following the explicit callback fix. These three cases are render checks, not save +or backend-persistence tests. The UI Vitest project passed all 8,176 tests in 728 +files. The full `pnpm test:run` reported a 15-second timeout in the unchanged backend +case `connects advertised Workspace gmail.read without mutating definitions in +authenticated`. That exact case passed standalone without changing expectations +or timeouts. The remaining broad run was stopped after this failure; a complete +repository suite pass is not established. + +Automated review found missing directly rendered subjects in the initial dependency +list. Expanded the catalog across affected suites, including relative production +imports, all direct non-primitive subjects in local wrappers, and the primitives +that foundation stories demonstrate. Explanatory review guides keep their separate +guide classification. This records change detection, not new parity +acceptance. Added a real CSF parser regression for nested discovery, include/exclude +rules, stable explicit IDs, and play counts. + +## Feature organization follow-up — 2026-10-12 + +Source baseline: `75bd3c977cd828d5222823850339e8204f40eb47` (parent PR #16046). +The 223 story files now live under their primary feature area in +`ui/storybook/stories/`. The sidebar uses the feature map's section and feature +labels in map order. Existing scenario groups remain below those labels. +Overview and shared Foundations have dedicated homes. Areas without stories do +not get empty sidebar groups; their gaps remain visible in the catalog. + +Nineteen supporting files moved to `ui/storybook/support/`, including fixtures, +shared wrappers, guide READMEs, an HTML fixture, and a fixture test. Relative imports, +active documentation, catalog sources, and replacement references follow the +moves. Classification and review scope did not change. The upstream feature map +and its skill are unchanged. + +Every suite now has an explicit meta ID, recorded as `stableId` in the catalog. +The built index comparison preserved all 2,210 story IDs, the single docs ID, and +all entry names. No story was removed or added. Catalog validation checks primary +feature folders, sidebar prefixes, and stable meta IDs; its nine tests passed. + +The Storybook build, UI typecheck, all 8,176 UI tests in 728 files, token gates, +map checks, and the maintenance-skill validator passed. A source comparison found +only changed meta titles/IDs, rebased relative references, and updated fixture +path text in story files. The full repository suite was not repeated for these +file moves; the prior full-suite limitation above still applies. + +Local Chromium verified the sidebar's section/feature order, rendered the guide, +searched `steering/queue`, and followed its link to the unchanged +`composer-steering-and-queued-messages--queued-while-running` ID. The nine play +cases listed above and `fast-responses-01-settings--empty` all reached +`storyFinished: success` without page exceptions after relocation. This is a +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. + +## Remaining review work + +1. Review the 188 unreviewed suites by capability and production subject. Compare + state transitions, payloads, errors, gates, and reset behavior before promoting + a suite. Start with large route wrappers and shared fixture dependencies. +2. Shared preview mocks can fall through to the original fetch; some route harnesses + return empty arrays for unmatched APIs. These can hide missing fixtures. Audit + affected endpoints per feature rather than treating a render as API coverage. +3. The managed-accounts test-drive story needs the separately owned local API/DB + fixture at port 4310. Its upstream providers are simulated. It was not exercised + against an unknown existing service. +4. Screenshot comparison is blocked: the existing baseline manifest has zero + snapshots and no published archive. Keep using the established Linux baseline + workflow when its baseline is published. Do not bless new baselines as part of + catalog cleanup. +5. Review fingerprints track declared sources, including feature recipes and local + support; they do not traverse every production dependency. Add missing subjects + when a review discovers them. Shared setup changes require reviewing callers. + +Use `maintain-storybook` to correct drift and `review-storybook` to record separate +source, render, interaction, visual-comparison, and production evidence. Both skills +consume the feature map; neither changes its upstream contract. + +## Native runtime prerequisite: scoped source follow-up — 2026-10-12 + +Source comparison: `9f59ff36cf2da963709ca19c06ab005510c835a6` against actual +master `4b958ecbd8f33a7782fe6f29ec825f80f636b833`. The reviewed +`maps/feature-map/runs-adapters.md` bytes have SHA-256 +`ca5aff86608b7d7987dc0ee814475a7b93e3faff1f99ce090429d2e932b7e16b`. + +Twenty-one entries in Runs, harnesses, and model accounts and Task creation and +lifecycle had review-fingerprint drift. Their 117 unique declared sources have +one changed input: the runs-adapters feature recipe. All 21 story files and the +other 116 declared production subjects, wrappers and fixture sources are +unchanged from master. The recipe adds bounded native startup and registration +retirement controls. It separates those provider-free controls from real daemon +and installed-provider proof; it changes no represented UI or fixture contract. + +Refreshed only those 21 reviewed fingerprints. No story, fixture, export, stable +ID, title, capability reference or product/implementation/data classification +changed. Existing mixed and unreviewed parity limitations remain. No fixture +correction is needed for this scoped prerequisite; this does not establish full +state parity for the affected suites. + +This follow-up is source inspection only. Rendering, play assertions, visual +comparisons and production provider acceptance were not run. Native lifecycle +behavior remains with the linked transport controls and their existing hosted +CI fixtures. Catalog validation proves structure and reviewed dependency hashes, +not that these live journeys passed. + +## Claude/Codex runner composition review — 2026-10-12 + +Source context: canonical runtime prerequisite +`64c074fb8da1b31f8d6042c4fef48dc4164e759b` composed with the Codex dependency +and local Claude behavior at `304e95c320`; the catalog/story changes in this +checkpoint are recorded below. This pass covers changed source dependencies and +six selected fixture interactions. It is not a full state-parity or Linux visual +baseline comparison, and it does not qualify providers or default rollout. + +The shared preview now supplies the production general-settings response and +resolved runner-discovery fields required by the matching-discovery guard. Its +Claude/Codex defaults explicitly simulate Legacy; the independent Codex creation +fixture simulates a qualified native default. The native inventory is not added +twice. Opus 5.5 joins the model metadata without replacing explicit older models. +All fixtures remain isolated from live providers and production accounts. + +Fifty-three affected entries retain their previous current/mixed/historical/ +unreviewed classifications. Source changes include shared runner controls, +branding, model/effort/default discovery, and the associated recipes. Existing +Select subjects remain intact while the shared module adds the runner popover; +no new primitive coverage is inferred. Direct runner-control dependencies are +listed where the rendered form/wizard uses them. The new Codex creation file is +placed in the agents feature home with explicit stable ID +`agents-codex-runner-creation`; all eight existing IDs survive. All 2,223 current +story IDs match the pre-move inventory, and 224 files are accounted for. + +An owned Storybook 10.6.0 dev server on port 6195 and the in-app browser executed +these existing play functions. Each displayed `Story status: Pass`, with its +observable result checked and no captured browser errors: + +| Story ID | Observed result | +| --- | --- | +| `agents-codex-runner-creation--automatic-codex` | Runner shows Paperclip Runner as default with the old experimental gate disabled | +| `agents-codex-runner-creation--explicit-legacy` | Runner remains Legacy | +| `agents-codex-runner-creation--setup-failure` | Missing-runner failure is visible and the override stays available | +| `agents-codex-runner-creation--task-ready` | Native fixture setup reaches the task-ready confirmation | +| `onboarding-agent-arc--claude-api-key` | Claude API-key entry is visible; no live key is entered | +| `onboarding-agent-arc--connect-with-saved-claude-subscription` | Simulated saved connection advances to Review | + +Revisiting Automatic Codex after Task Ready restores the Connect step, its native +default and a disabled Confirmation step; no hired-agent state leaks. This is +fixture reset evidence, not persisted backend/session evidence. Other plays, +full themes/mobile variants in this Storybook, accessibility grading and Linux +baseline comparisons are not run. The retained product preview's separate mobile +inspection remains bound to its earlier compiled source, and final installed +product/provider checks remain pending. The catalog checker and nine checker +controls pass; UI noemit, maps and token gates pass on the composed source. diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json new file mode 100644 index 00000000000..de0a71af789 --- /dev/null +++ b/maps/storybook-map/catalog.json @@ -0,0 +1,5630 @@ +{ + "version": 1, + "sharedReview": { + "sources": [ + "ui/storybook/.storybook/main.ts", + "ui/storybook/.storybook/preview.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "maps/feature-map/README.md" + ], + "fingerprint": "52643891a15456fa1afef6d94b884bec8d24faff67594c97b5af9bda2ce4311b" + }, + "entries": [ + { + "file": "ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx", + "capabilities": [ + "connection-setup/catalog" + ], + "purpose": "Apps/Canonical icon registry", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/pages/apps/AppLogo.tsx", + "ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx" + ], + "fingerprint": "c2cabf44446b0ae5ffba2959df65fb9aba16e635b9b5e63fa1a0e36cfb959f8b", + "stableId": "apps-canonical-icon-registry", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Canonical icon registry" + }, + { + "file": "ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx", + "capabilities": [ + "connection-setup/credentials", + "connection-setup/failure" + ], + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/components/JsonSchemaForm.tsx", + "ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx", + "ui/src/features/connections/remote-mcp/providers.ts", + "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" + ], + "fingerprint": "cb8802f6362599a2aa164520a07a15ae3d41d4ff90173cfcbb9f8b3cefae0dc4", + "stableId": "apps-connections-arcade", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade" + }, + { + "file": "ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx", + "capabilities": [ + "connection-setup/credentials", + "connection-setup/failure" + ], + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/components/JsonSchemaForm.tsx", + "ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx", + "ui/src/features/connections/remote-mcp/providers.ts", + "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" + ], + "fingerprint": "f7623b707dd4982dc583621f2b7b478544b2873653eff36cb7e62e2d2d1bbce3", + "stableId": "apps-connections-composio", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Composio" + }, + { + "file": "ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx", + "capabilities": [ + "connection-setup/identity-and-access" + ], + "purpose": "Apps/Connections/Provider choice", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/components/JsonSchemaForm.tsx", + "ui/src/components/task-chat/QuestionForm.tsx", + "ui/src/features/connections/remote-mcp/RemoteMcpAccountChoice.tsx", + "ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx", + "ui/src/features/connections/remote-mcp/providers.ts", + "ui/src/features/connections/remote-mcp/types.ts", + "ui/storybook/fixtures/remoteMcpConnections.ts", + "ui/storybook/prototypes/RemoteMcpConnectionReview.tsx", + "ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx" + ], + "fingerprint": "046ab725fad4b30c0b6c51d0064725e9688012a67c9106290fde7cb9413c3715", + "stableId": "apps-connections-provider-choice", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice" + }, + { + "file": "ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx", + "capabilities": [ + "connection-setup/credentials", + "connection-setup/failure" + ], + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/components/JsonSchemaForm.tsx", + "ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx", + "ui/src/features/connections/remote-mcp/providers.ts", + "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" + ], + "fingerprint": "4617104a6f8867e3eaa996a643bcefa03b158fe9be0c43c0d77f578da42a0cf2", + "stableId": "apps-connections-executor", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Executor" + }, + { + "file": "ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx", + "capabilities": [ + "connection-setup/credentials", + "connection-setup/failure" + ], + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/components/JsonSchemaForm.tsx", + "ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx", + "ui/src/features/connections/remote-mcp/providers.ts", + "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" + ], + "fingerprint": "6ab45d2c88183f3f63efaa1a1a6ad9eb05436f94fc4e1ba67fcfb97345ee8ffb", + "stableId": "apps-connections-zapier", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier" + }, + { + "file": "ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx", + "capabilities": [ + "gateways-profiles/profiles" + ], + "purpose": "Tools/Access profiles", + "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", + "sources": [ + "maps/feature-map/gateways-profiles.md", + "ui/src/pages/AgentToolsTab.tsx", + "ui/src/pages/tools/profiles/ProfileDetail.tsx", + "ui/src/pages/tools/profiles/ProfileWizard.tsx", + "ui/src/pages/tools/profiles/ProfilesIndex.tsx", + "ui/src/pages/tools/profiles/WizardToolsStep.tsx", + "ui/src/pages/tools/profiles/profile-model.ts", + "ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx" + ], + "fingerprint": "23dfe9b1750b7eae8e6eae652f820ae2ff3e1415ce1b35e27717e24c3eed681b", + "stableId": "tools-access-profiles", + "sidebarTitle": "Apps, channels, and extensions/Tool gateways and access profiles/Tools/Access profiles" + }, + { + "file": "ui/storybook/stories/app-permissions/action-card.stories.tsx", + "capabilities": [ + "app-permissions/test-action", + "app-permissions/action-policy" + ], + "purpose": "Tools & Access/ActionCard", + "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", + "sources": [ + "maps/feature-map/app-permissions.md", + "ui/src/components/actions/ActionCard.tsx", + "ui/storybook/stories/app-permissions/action-card.stories.tsx" + ], + "fingerprint": "37849ece1ec378382e41b84ee23d506ee9cbcee923e421b6fc9929fc4d420cc5", + "stableId": "tools-access-actioncard", + "sidebarTitle": "Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard" + }, + { + "file": "ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx", + "capabilities": [ + "app-permissions/test-action", + "app-permissions/action-policy" + ], + "purpose": "Apps/Action permissions test", + "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", + "sources": [ + "maps/feature-map/app-permissions.md", + "ui/src/pages/apps/app-detail/PermissionsPanel.tsx", + "ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx" + ], + "fingerprint": "7804d31cc4bf5fabd5191b201a85258f9d4c9a670331ac611628f8001114d687", + "stableId": "apps-action-permissions-test", + "sidebarTitle": "Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test" + }, + { + "file": "ui/storybook/stories/activity/activity-feed.stories.tsx", + "capabilities": [ + "activity/activity", + "gateways-profiles/activity" + ], + "purpose": "Tools/Activity (audit feed)", + "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", + "sources": [ + "maps/feature-map/activity.md", + "maps/feature-map/gateways-profiles.md", + "ui/src/pages/tools/AuditTab.tsx", + "ui/storybook/stories/activity/activity-feed.stories.tsx" + ], + "fingerprint": "e38af3b7963caf0cc3b13f7681da370a579f97ad78a6064559fbb18aeb73b4ed", + "stableId": "tools-activity-audit-feed", + "sidebarTitle": "Oversight and operation/Dashboards and audit trails/Tools/Activity (audit feed)" + }, + { + "file": "ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx", + "capabilities": [ + "agent-chat/discovery", + "agent-chat/history" + ], + "purpose": "Design explorations/Agent chat sidebar/Pages", + "status": "current", + "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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "ui/src/components/AgentChatPicker.tsx", + "ui/src/components/AgentIconPicker.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/SidebarAgentChats.tsx", + "ui/src/pages/AgentChat.tsx", + "ui/src/pages/AgentChats.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.tsx", + "ui/src/pages/IssueDetail.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/agent-chat-sidebar/AgentChatSidebarReview.tsx", + "ui/storybook/prototypes/agent-chat-sidebar/fixtures.ts", + "ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx", + "ui/storybook/prototypes/agent-chat/AgentChatSidebar.tsx", + "ui/storybook/prototypes/chat-entry/ChatEntryReview.tsx", + "ui/storybook/stories/agent-chat/agent-chat-sidebar.stories.tsx" + ], + "fingerprint": "b0b6a9512f212b17ad9b42f284ee886c99916c724795c40131f7e91c1d5901c8", + "stableId": "design-explorations-agent-chat-sidebar-pages", + "sidebarTitle": "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages" + }, + { + "file": "ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx", + "capabilities": [ + "questions-and-approvals/dismiss-and-reopen", + "agent-chat/history" + ], + "purpose": "Chat & Comments/Agent Chat Unanswered Questions", + "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", + "sources": [ + "maps/feature-map/agent-chat.md", + "maps/feature-map/questions-and-approvals.md", + "ui/src/components/TaskChatThread.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/unansweredQuestionChat.tsx", + "ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx" + ], + "fingerprint": "a55976ae6cf404c1febe64fb674e1096e8e58dc0360001a5a12af202e0b40908", + "stableId": "chat-comments-agent-chat-unanswered-questions", + "sidebarTitle": "Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions" + }, + { + "file": "ui/storybook/stories/agent-chat/agent-chat.stories.tsx", + "capabilities": [ + "agent-chat/discovery", + "agent-chat/history" + ], + "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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "ui/src/components/AgentChatPicker.tsx", + "ui/src/components/AgentIconPicker.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/SidebarAgentChats.tsx", + "ui/src/pages/AgentChat.tsx", + "ui/src/pages/AgentChats.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.tsx", + "ui/src/pages/IssueDetail.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/agent-chat-sidebar/AgentChatSidebarReview.tsx", + "ui/storybook/prototypes/agent-chat-sidebar/fixtures.ts", + "ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx", + "ui/storybook/prototypes/agent-chat/AgentChatSidebar.tsx", + "ui/storybook/prototypes/chat-entry/ChatEntryReview.tsx", + "ui/storybook/stories/agent-chat/agent-chat.stories.tsx" + ], + "fingerprint": "938a858135e7586e5289afee3c4377b040d413b8693dac6140e3ff2406e2a81f", + "stableId": "design-explorations-agent-chat", + "sidebarTitle": "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat" + }, + { + "file": "ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx", + "capabilities": [ + "runs-adapters/validation", + "agents/hire" + ], + "purpose": "Onboarding/Saved connections", + "status": "current", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "maps/feature-map/runs-adapters.md", + "ui/src/components/new-agent/AgentProviderConnection.tsx", + "ui/storybook/fixtures/onboardingEnvironment.ts", + "ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx" + ], + "fingerprint": "bee32d44f10f3d974c9b1d7112040ef88188f4ea94dba66b02440e5ed35e67f5", + "stableId": "onboarding-saved-connections", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections" + }, + { + "file": "ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx", + "capabilities": [ + "agent-chat/discovery", + "agent-chat/history" + ], + "purpose": "Design explorations/Agent chat sidebar/Components", + "status": "current", + "implementation": "production", + "boundary": "fixtures", + "notes": "Support module re-exports production AgentConversationSidebar; search/selection use fixture callbacks.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "ui/src/components/AgentConversationSidebar.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/agent-chat-sidebar/AgentConversationSidebar.tsx", + "ui/storybook/prototypes/agent-chat-sidebar/fixtures.ts", + "ui/storybook/stories/agent-chat/agent-conversation-sidebar.stories.tsx" + ], + "fingerprint": "2a57c402c2036a5c934b9d8da71699b3c4c93f023d12b9f80834b04fd16a4d7e", + "stableId": "design-explorations-agent-chat-sidebar-components", + "sidebarTitle": "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components" + }, + { + "file": "ui/storybook/stories/agents/agent-detail.stories.tsx", + "capabilities": [ + "agents/lifecycle", + "agents/instructions" + ], + "purpose": "Pages/Agent 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. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/pages/AgentDetail.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/agent-detail.stories.tsx" + ], + "fingerprint": "8695183946a769b13510c1b091f09cc67cc6ced4a69a1e326c530a08989e6f93", + "stableId": "pages-agent-detail", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail" + }, + { + "file": "ui/storybook/stories/agents/agent-files.stories.tsx", + "capabilities": [ + "agents/instructions" + ], + "purpose": "Agents/Persistent files", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/pages/AgentDetail.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/agent-files.stories.tsx" + ], + "fingerprint": "fae12e8561520bb240f4cda03c979d9ba6c5099369ea9d55e715f02380d806d2", + "stableId": "agents-persistent-files", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files" + }, + { + "file": "ui/storybook/stories/agents/agent-management.stories.tsx", + "capabilities": [ + "agents/lifecycle", + "agents/instructions" + ], + "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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/components/ActiveAgentsPanel.tsx", + "ui/src/components/AgentActionButtons.tsx", + "ui/src/components/AgentConfigForm.tsx", + "ui/src/components/AgentIconPicker.tsx", + "ui/src/components/AgentProperties.tsx", + "ui/src/components/AgentRunnerSelect.tsx", + "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" + ], + "fingerprint": "8943293cd39e2ff48719dd8d31129380c17e44dd55038b46671112845b4483a0", + "stableId": "product-agent-management", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management" + }, + { + "file": "ui/storybook/stories/agents/agent-persona-pages.stories.tsx", + "capabilities": [ + "agents/organization", + "navigation-preferences/profile" + ], + "purpose": "Agents/Personas/Full pages", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "maps/feature-map/navigation-preferences.md", + "ui/src/components/Layout.tsx", + "ui/src/components/new-agent/AgentBasicsDialog.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.tsx", + "ui/src/pages/Dashboard.tsx", + "ui/src/pages/IssueDetail.tsx", + "ui/src/pages/NewAgent.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/agent-persona-pages.stories.tsx" + ], + "fingerprint": "1683039c78c42ac1b59f3520003bcf3b9f3f71d51ef4b064c9c7fdb89f6dc91d", + "stableId": "agents-personas-full-pages", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages" + }, + { + "file": "ui/storybook/stories/agents/agent-personas.stories.tsx", + "capabilities": [ + "agents/organization", + "navigation-preferences/profile" + ], + "purpose": "Agents/Personas", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "maps/feature-map/navigation-preferences.md", + "ui/src/components/AgentAvatar.tsx", + "ui/src/components/AgentCharacter.tsx", + "ui/src/components/AgentIdentity.tsx", + "ui/src/components/ui/button.tsx", + "ui/storybook/stories/agents/agent-personas.stories.tsx" + ], + "fingerprint": "5da5911f57668a7b9820d8d7bbec9b52e380d89a8353ab2548b2f9af6c0d66df", + "stableId": "agents-personas", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas" + }, + { + "file": "ui/storybook/stories/agents/agent-settings-redesign.stories.tsx", + "capabilities": [ + "agents/lifecycle", + "agents/instructions" + ], + "purpose": "Agents/Configuration refresh", + "status": "mixed", + "implementation": "mixed", + "boundary": "fixtures", + "notes": "Production configuration panels in a locally composed settings shell; full AgentDetail route parity remains unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/components/AgentActionButtons.tsx", + "ui/src/components/AgentConfigForm.tsx", + "ui/src/components/AgentContextualSidebar.tsx", + "ui/src/components/AgentRunnerSelect.tsx", + "ui/src/components/NewIssueDialog.tsx", + "ui/src/components/RuntimeTestCard.tsx", + "ui/src/components/onboarding/PillGuy.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/AgentToolsTab.tsx", + "ui/src/pages/agent-detail-navigation.ts", + "ui/src/pages/agent-skills/AgentSkillsTab.tsx", + "ui/storybook/fixtures/onboardingEnvironment.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/RuntimeTestCard.tsx", + "ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx", + "ui/storybook/prototypes/agent-settings/fixtures.ts", + "ui/storybook/prototypes/new-agent-fixtures.ts", + "ui/storybook/stories/agents/agent-settings-redesign.stories.tsx" + ], + "fingerprint": "07609258b76d1e41ad9400e10338b3b129e6f14f372977aeacad2aa95a246e8f", + "stableId": "agents-configuration-refresh", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh" + }, + { + "file": "ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx", + "capabilities": [ + "connection-setup/channel-setup", + "gateways-profiles/client-connect" + ], + "purpose": "Components/Agent setup prompt", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "maps/feature-map/gateways-profiles.md", + "ui/src/components/AgentSetupPrompt.tsx", + "ui/src/components/IssueContinuationHandoff.tsx", + "ui/src/components/routine-triggers/TriggerWizard.tsx", + "ui/src/components/routine-triggers/WebhookFields.tsx", + "ui/src/pages/apps/chat/GitHubSetupPrompt.tsx", + "ui/src/pages/apps/chat/SlackSetupPrompt.tsx", + "ui/src/pages/tools/McpConfigHelpDialog.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/routineTriggerWizard.tsx", + "ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx" + ], + "fingerprint": "5628f9ddbb4484f964e4b95d3c342e982f9b10e1c39e8cfbaa49a0ab2cb2a484", + "stableId": "components-agent-setup-prompt", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Components/Agent setup prompt" + }, + { + "file": "ui/storybook/stories/skills/agent-skill-row.stories.tsx", + "capabilities": [ + "skills/policy" + ], + "purpose": "Agent Skills/AgentSkillRow", + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/components/icons/github-icon.tsx", + "ui/src/pages/agent-skills/AgentSkillRow.tsx", + "ui/storybook/stories/skills/agent-skill-row.stories.tsx" + ], + "fingerprint": "4eeb2029b5b1002afe393ab41da89a949c28d955130caa9feee4068b4b63fd2f", + "stableId": "agent-skills-agentskillrow", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Agent Skills/AgentSkillRow" + }, + { + "file": "ui/storybook/stories/skills/agent-skills-edge.stories.tsx", + "capabilities": [ + "skills/policy" + ], + "purpose": "QA / Agent skills edge states", + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/pages/agent-skills/AgentSkillsTab.tsx", + "ui/storybook/stories/skills/agent-skills-edge.stories.tsx" + ], + "fingerprint": "e2beaa5b5d5fb0b9ee34935d05548d0448c250129b3c2377eb7973f284fdc452", + "stableId": "qa-agent-skills-edge-states", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/QA / Agent skills edge states" + }, + { + "file": "ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx", + "capabilities": [ + "chat-channels/providers", + "connection-setup/channel-setup" + ], + "purpose": "Connections/AgentMail setup", + "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", + "sources": [ + "maps/feature-map/chat-channels.md", + "maps/feature-map/connection-setup.md", + "ui/src/components/chat/ChatSetupNavigation.tsx", + "ui/src/pages/apps/chat/EmailEndpointSetup.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx" + ], + "fingerprint": "a1d017e03f98f45224826a5c6a2bebeecb8a9fc42eba667a8164d0be6168f735", + "stableId": "connections-agentmail-setup", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail setup" + }, + { + "file": "ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx", + "capabilities": [ + "chat-channels/lifecycle" + ], + "purpose": "Connections/AgentMail 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", + "sources": [ + "maps/feature-map/chat-channels.md", + "ui/src/pages/apps/chat/EmailEndpointSetup.tsx", + "ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx" + ], + "fingerprint": "1fda3e7570e13c73c9bb9908efe05508e3eb14c716a7c9462ae6a370b77b5a5c", + "stableId": "connections-agentmail-settings", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail settings" + }, + { + "file": "ui/storybook/stories/chat-channels/agentmail.stories.tsx", + "capabilities": [ + "chat-channels/conversation" + ], + "purpose": "Connections/AgentMail tasks", + "status": "unreviewed", + "implementation": "prototype", + "boundary": "fixtures", + "notes": "Local EmailTaskExperience combines production composer/bubbles with simulated email lifecycle; compare against IssueDetail before claiming page parity.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/chat-channels.md", + "ui/src/components/AgentIconPicker.tsx", + "ui/src/components/StatusBadge.tsx", + "ui/src/components/task-chat/TaskChatBubble.tsx", + "ui/src/components/task-chat/TaskChatComposer.tsx", + "ui/src/components/task-chat/presentation-mode.tsx", + "ui/storybook/stories/chat-channels/agentmail.stories.tsx" + ], + "fingerprint": "9f476aac6ea2ecd4a5cb72226b460fc1c78e3bfda05926d6bfd1b8581e68fdcd", + "stableId": "connections-agentmail-tasks", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks" + }, + { + "file": "ui/storybook/stories/skills/agents-using-skill.stories.tsx", + "capabilities": [ + "skills/policy" + ], + "purpose": "Skill Studio/AgentsUsingSkill", + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/components/skill-studio/AgentsUsingSkillDialog.tsx", + "ui/storybook/stories/skills/agents-using-skill.stories.tsx" + ], + "fingerprint": "28d0e3a9ffe07d1c48b2e7e73cd67df55047c47cec1163779d1a9c46beb981ef", + "stableId": "skill-studio-agentsusingskill", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/AgentsUsingSkill" + }, + { + "file": "ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Usage limits", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/ai-connections/ManagedAiConnectionDetails.tsx", + "ui/storybook/prototypes/AiConnectionUsageReview.tsx", + "ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx" + ], + "fingerprint": "adf0fc7739ecded2716d63e54c9808ffa2e475b61e87d34b83969947c293614a", + "stableId": "ai-connections-usage-limits", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Usage limits" + }, + { + "file": "ui/storybook/stories/runs-adapters/ai-connections.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Review", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/BreadcrumbBar.tsx", + "ui/src/components/ai-connections/AiConnectionAccountControls.tsx", + "ui/src/components/ai-connections/AiConnectionAuth.tsx", + "ui/src/components/ai-connections/AiConnectionManagement.tsx", + "ui/src/components/ai-connections/AiConnectionPicker.tsx", + "ui/src/components/ai-connections/model.ts", + "ui/src/components/onboarding/CredentialModeLink.tsx", + "ui/src/components/onboarding/ModelSourceTiles.tsx", + "ui/src/features/connections/ConnectionIntentInteractionBody.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/src/pages/apps/AppDetail.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/storybook/fixtures/aiConnections.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/AiConnectionsReview.tsx", + "ui/storybook/prototypes/AiConnectorPages.tsx", + "ui/storybook/prototypes/AiReviewFrame.tsx", + "ui/storybook/prototypes/AiTaskConnectionReview.tsx", + "ui/storybook/stories/runs-adapters/ai-connections.stories.tsx" + ], + "fingerprint": "c038f495cf2c52350d2111816226f6c6ec3d9aaadd53689adb80593bfd71abbd", + "stableId": "ai-connections-review", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Review" + }, + { + "file": "ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx", + "capabilities": [ + "navigation-preferences/announcements" + ], + "purpose": "Announcements/AnnouncementCard", + "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", + "sources": [ + "maps/feature-map/navigation-preferences.md", + "ui/src/components/AnnouncementCard.tsx", + "ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx" + ], + "fingerprint": "39d0cbdb22d29fee065260ad54fb01da1df21897f3228275875250793ef23bf8", + "stableId": "announcements-announcementcard", + "sidebarTitle": "Oversight and operation/Navigation, profile, and announcements/Announcements/AnnouncementCard" + }, + { + "file": "ui/storybook/stories/activity/app-activity-lifecycle.stories.tsx", + "capabilities": [ + "activity/activity", + "gateways-profiles/activity" + ], + "purpose": "Apps/App detail · Activity (lifecycle)", + "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", + "sources": [ + "maps/feature-map/activity.md", + "maps/feature-map/gateways-profiles.md", + "ui/src/pages/apps/app-detail/ActivityPanel.tsx", + "ui/src/pages/apps/app-detail/types.ts", + "ui/storybook/stories/activity/app-activity-lifecycle.stories.tsx" + ], + "fingerprint": "1642f6a6692ccd3ac058d1841f388607b3379c683711bbe558aad3cc79ffc077", + "stableId": "apps-app-detail-·-activity-lifecycle", + "sidebarTitle": "Oversight and operation/Dashboards and audit trails/Apps/App detail · Activity (lifecycle)" + }, + { + "file": "ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx", + "capabilities": [ + "connection-setup/catalog" + ], + "purpose": "Apps/Catalog source selection", + "status": "proposed", + "implementation": "prototype", + "boundary": "fixtures", + "notes": "Local catalog composition uses shipped cards and filters; retains design exploration purpose, not acceptance of the Apps route.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/pages/apps/Browse.tsx", + "ui/src/pages/apps/CatalogSourceFilters.tsx", + "ui/storybook/prototypes/ConnectorCatalogNavigation.tsx", + "ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx" + ], + "fingerprint": "5157a890fe6825a1f89d138a582e5b11791aceef4a0d7cfb6c5cc968c06ee7f6", + "stableId": "apps-catalog-source-selection", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Catalog source selection" + }, + { + "file": "ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx", + "capabilities": [ + "connection-setup/catalog" + ], + "purpose": "Apps/Connect discoverability (PAP-11091)", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/pages/apps/AppsConnect.tsx", + "ui/src/pages/tools/PasteConfigTab.tsx", + "ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx" + ], + "fingerprint": "bd84a617cba9cc83a08f36d133e954a54a8f60c94ae805382736f560e41c3bdd", + "stableId": "apps-connect-discoverability-pap-11091", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connect discoverability (PAP-11091)" + }, + { + "file": "ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx", + "capabilities": [ + "connection-setup/identity-and-access" + ], + "purpose": "Apps/Connect name at create time (PAP-11283)", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/pages/apps/AppsConnect.tsx", + "ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx" + ], + "fingerprint": "cf644220c06d38aa70e94b35519698b7bb4f00cc0565dc7d9cd4840293d15630", + "stableId": "apps-connect-name-at-create-time-pap-11283", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connect name at create time (PAP-11283)" + }, + { + "file": "ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx", + "capabilities": [ + "app-permissions/account-maintenance" + ], + "purpose": "Apps/Managed accounts", + "status": "current", + "implementation": "production", + "boundary": "fixture-server", + "notes": "Production Browse requires owned tests/aggregator-accounts/test-drive.ts on localhost:4310; providers are simulated. Not an offline story.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/app-permissions.md", + "ui/src/pages/apps/Browse.tsx", + "ui/storybook/prototypes/ManagedAggregatorAccounts.tsx", + "ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx" + ], + "fingerprint": "a1627ade7b4327780b978e7f58fc54d70f30337ef109c8ef2afc793450045b1b", + "stableId": "apps-managed-accounts", + "sidebarTitle": "Apps, channels, and extensions/App access and action permissions/Apps/Managed accounts" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx", + "capabilities": [ + "documents-artifacts/work-products" + ], + "purpose": "Explorations/Artifact cards/Commit", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/artifacts/RichArtifactCards.tsx", + "ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx", + "ui/storybook/support/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "7a6d3f80bcdbdc75b9d4b212ac5ad48fe33ec983cadabd2d9d8a75f4bd373ccb", + "stableId": "explorations-artifact-cards-commit", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Commit" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx", + "capabilities": [ + "documents-artifacts/work-products" + ], + "purpose": "Explorations/Artifact cards/Data", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/artifacts/RichArtifactCards.tsx", + "ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx", + "ui/storybook/support/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "d811c07d4b2febcca8880f2cbb370d5fd6812aab11981122e1ff0cd452026ea1", + "stableId": "explorations-artifact-cards-data", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Data" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx", + "capabilities": [ + "documents-artifacts/work-products" + ], + "purpose": "Explorations/Artifact cards/Document", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/artifacts/RichArtifactCards.tsx", + "ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx", + "ui/storybook/support/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "84f56eaeecd79297c6aaef270770229d3692325313dc4838ca1bf758c518abb0", + "stableId": "explorations-artifact-cards-document", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Document" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx", + "capabilities": [ + "documents-artifacts/work-products" + ], + "purpose": "Explorations/Artifact cards/File", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/artifacts/RichArtifactCards.tsx", + "ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx", + "ui/storybook/support/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "d2ccd3e4eb8272bfc2e54292141e6367d169979933c27f9d099e437000c5522f", + "stableId": "explorations-artifact-cards-file", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/File" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx", + "capabilities": [ + "documents-artifacts/work-products" + ], + "purpose": "Explorations/Artifact cards/Image", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/artifacts/RichArtifactCards.tsx", + "ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx", + "ui/storybook/support/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "3f56a6aa81d4ccbbd5666840bc017a813482e9a40df61c0ac18aa6d42f7c860a", + "stableId": "explorations-artifact-cards-image", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Image" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx", + "capabilities": [ + "documents-artifacts/work-products" + ], + "purpose": "Explorations/Artifact cards/Link preview", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/artifacts/RichArtifactCards.tsx", + "ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx", + "ui/storybook/support/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "30807b316e1ddc32e3288927283512507974ef537dee5d7ecb11936b6bcb7131", + "stableId": "explorations-artifact-cards-link-preview", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Link preview" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx", + "capabilities": [ + "documents-artifacts/work-products" + ], + "purpose": "Explorations/Artifact cards/Pull request", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/artifacts/RichArtifactCards.tsx", + "ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx", + "ui/storybook/support/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "21959a663489bd8ab8389ef1492a2b459c7159efa87033253d2430827f288bfc", + "stableId": "explorations-artifact-cards-pull-request", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Pull request" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx", + "capabilities": [ + "documents-artifacts/work-products" + ], + "purpose": "Explorations/Artifact cards/Video", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/artifacts/RichArtifactCards.tsx", + "ui/storybook/support/artifact-cards/storyConfig.tsx", + "ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx" + ], + "fingerprint": "7187d0b7c3486eaa0e9b7abe375eb11db9c993701ee23edf3f1703a99c1acdd2", + "stableId": "explorations-artifact-cards-video", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Video" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx", + "capabilities": [ + "documents-artifacts/attachments" + ], + "purpose": "Artifact upload/01 Artifacts pane", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/artifacts/ArtifactUploadArea.tsx", + "ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx", + "ui/src/components/side-panel/SidePanelFrame.tsx", + "ui/src/components/side-panel/SidePanelMobileTabs.tsx", + "ui/src/components/side-panel/SidePanelTabs.tsx", + "ui/src/components/side-panel/types.ts", + "ui/src/components/task-side-panel/TaskDocumentPanel.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx", + "ui/storybook/support/artifact-upload/UploadPreview.tsx", + "ui/storybook/support/artifact-upload/fixtures.ts" + ], + "fingerprint": "97036aef581ed62b1032742741c139e710f0f2a9e634c75e507ce5dc38d938e5", + "stableId": "artifact-upload-01-artifacts-pane", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Artifact upload/01 Artifacts pane" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx", + "capabilities": [ + "documents-artifacts/attachments" + ], + "purpose": "Artifact upload/02 Paused task 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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/Layout.tsx", + "ui/src/components/StatusBadge.tsx", + "ui/src/components/artifacts/ArtifactUploadArea.tsx", + "ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx", + "ui/src/components/side-panel/SidePanelFrame.tsx", + "ui/src/components/side-panel/SidePanelMobileTabs.tsx", + "ui/src/components/side-panel/SidePanelTabs.tsx", + "ui/src/components/side-panel/types.ts", + "ui/src/components/task-chat/TaskChatBubble.tsx", + "ui/src/components/task-chat/TaskChatPausedTakeover.tsx", + "ui/src/components/task-side-panel/TaskDocumentPanel.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx", + "ui/storybook/support/artifact-upload/UploadPreview.tsx", + "ui/storybook/support/artifact-upload/fixtures.ts" + ], + "fingerprint": "ce98222afff00d7dfdfa7f84fd5e909bfe49fe8e5c1bd0f7f188942d11eafec7", + "stableId": "artifact-upload-02-paused-task-journey", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Artifact upload/02 Paused task journey" + }, + { + "file": "ui/storybook/stories/documents-artifacts/artifacts.stories.tsx", + "capabilities": [ + "documents-artifacts/library", + "documents-artifacts/work-products" + ], + "purpose": "Pages/Artifacts", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/EmptyState.tsx", + "ui/src/components/artifacts/ArtifactCard.tsx", + "ui/src/pages/Artifacts.tsx", + "ui/storybook/stories/documents-artifacts/artifacts.stories.tsx" + ], + "fingerprint": "8752ed42735eb3e31b788c3d6661109a5a1230b667575cf5b4f78ba4f2f950af", + "stableId": "pages-artifacts", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Pages/Artifacts" + }, + { + "file": "ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx", + "capabilities": [ + "tasks/completion", + "recovery/diagnosis" + ], + "purpose": "Paperclip/Assigned Backlog Safeguards", + "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", + "sources": [ + "maps/feature-map/recovery.md", + "maps/feature-map/tasks.md", + "ui/src/components/IssueAssignedBacklogNotice.tsx", + "ui/src/components/IssueBlockedNotice.tsx", + "ui/src/components/IssueRow.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx" + ], + "fingerprint": "3a3610f557ca0b31528e7df0a2faf0885132ba6c947380588bc63b144c14833e", + "stableId": "paperclip-assigned-backlog-safeguards", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards" + }, + { + "file": "ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx", + "capabilities": [ + "inbox-search/triage", + "recovery/diagnosis" + ], + "purpose": "Product/Inbox/Blocked tab", + "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", + "sources": [ + "maps/feature-map/inbox-search.md", + "maps/feature-map/recovery.md", + "ui/src/components/BlockedInboxView.tsx", + "ui/src/components/BlockedReasonChip.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx" + ], + "fingerprint": "d7406369152782fe626f9be02cecde54a7abc58753fc9e5a5b0d167f2a5df3f2", + "stableId": "product-inbox-blocked-tab", + "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab" + }, + { + "file": "ui/storybook/stories/workspaces/browser-use-activity.stories.tsx", + "capabilities": [ + "workspaces/services", + "tasks/discussion" + ], + "purpose": "Tasks/Browser Use/Task activity", + "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", + "sources": [ + "maps/feature-map/tasks.md", + "maps/feature-map/workspaces.md", + "ui/src/components/task-chat/TaskChatThreadView.tsx", + "ui/src/components/task-side-panel/TaskBrowserActivity.tsx", + "ui/src/components/task-side-panel/TaskBrowserPanel.tsx", + "ui/storybook/fixtures/browser-use.tsx", + "ui/storybook/stories/workspaces/browser-use-activity.stories.tsx" + ], + "fingerprint": "0fb038daa922a0b61864f4ebf0096a50c9caf5dc290887cbf97a0bb63dad22af", + "stableId": "tasks-browser-use-task-activity", + "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity" + }, + { + "file": "ui/storybook/stories/workspaces/browser-use-footer.stories.tsx", + "capabilities": [ + "workspaces/services", + "tasks/discussion" + ], + "purpose": "Tasks/Browser Use/Footer", + "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", + "sources": [ + "maps/feature-map/tasks.md", + "maps/feature-map/workspaces.md", + "ui/src/components/task-side-panel/TaskBrowserFooter.tsx", + "ui/src/components/task-side-panel/TaskBrowserPanel.tsx", + "ui/storybook/fixtures/browser-use.tsx", + "ui/storybook/stories/workspaces/browser-use-footer.stories.tsx" + ], + "fingerprint": "f42487c77335ac64cbe0822c51b1706098f26d9b5823e20837089f261277fd58", + "stableId": "tasks-browser-use-footer", + "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer" + }, + { + "file": "ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx", + "capabilities": [ + "connection-setup/credentials" + ], + "purpose": "Connections/Browser Use Cloud/Settings", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/components/task-side-panel/TaskBrowserPanel.tsx", + "ui/src/pages/apps/app-detail/BrowserUseSettingsPanel.tsx", + "ui/storybook/fixtures/browser-use.tsx", + "ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx" + ], + "fingerprint": "03d6a432d16ecfb9bea1c75108ee7c70d23d283b47bd28f287547f4b6c23f7f3", + "stableId": "connections-browser-use-cloud-settings", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Connections/Browser Use Cloud/Settings" + }, + { + "file": "ui/storybook/stories/workspaces/browser-use.stories.tsx", + "capabilities": [ + "workspaces/services", + "tasks/discussion" + ], + "purpose": "Tasks/Browser Use/Panel", + "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", + "sources": [ + "maps/feature-map/tasks.md", + "maps/feature-map/workspaces.md", + "ui/src/components/task-side-panel/TaskBrowserPanel.tsx", + "ui/src/components/task-side-panel/TaskSidePanel.tsx", + "ui/storybook/fixtures/browser-use.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/workspaces/browser-use.stories.tsx" + ], + "fingerprint": "ce0948c8a9dade2bcab999dccf4f5daa1ddbc4f3ecc72a70e88a1f40861d5199", + "stableId": "tasks-browser-use-panel", + "sidebarTitle": "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel" + }, + { + "file": "ui/storybook/stories/budgets-costs/budget-finance.stories.tsx", + "capabilities": [ + "budgets-costs/cost-reporting", + "budgets-costs/budgets" + ], + "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", + "sources": [ + "maps/feature-map/budgets-costs.md", + "ui/src/components/AccountingModelCard.tsx", + "ui/src/components/BillerSpendCard.tsx", + "ui/src/components/BudgetIncidentCard.tsx", + "ui/src/components/BudgetSidebarMarker.tsx", + "ui/src/components/ClaudeSubscriptionPanel.tsx", + "ui/src/components/CodexSubscriptionPanel.tsx", + "ui/src/components/FinanceBillerCard.tsx", + "ui/src/components/FinanceKindCard.tsx", + "ui/src/components/FinanceTimelineCard.tsx", + "ui/src/components/ProviderQuotaCard.tsx", + "ui/storybook/stories/budgets-costs/budget-finance.stories.tsx" + ], + "fingerprint": "b71fa2f405c1d671b092bd2d86b220429819950b100c5aed3b7afb2b4efae1d3", + "stableId": "product-budget-finance", + "sidebarTitle": "Oversight and operation/Costs and budgets/Product/Budget & Finance" + }, + { + "file": "ui/storybook/stories/agents/built-in-agents.stories.tsx", + "capabilities": [ + "agents/built-in" + ], + "purpose": "Product/Built-in Agents", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/components/BuiltInAgentBadges.tsx", + "ui/src/components/BuiltInBundlePanel.tsx", + "ui/src/components/ConfigureBuiltInAgentModal.tsx", + "ui/src/components/EmptyState.tsx", + "ui/src/components/EntityRow.tsx", + "ui/src/components/InlineBanner.tsx", + "ui/src/components/StatusBadge.tsx", + "ui/storybook/stories/agents/built-in-agents.stories.tsx" + ], + "fingerprint": "683fde37a5e966faacaaf872e7ccaa0c344921b919e61ec3863f983bd07dd7e1", + "stableId": "product-built-in-agents", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Product/Built-in Agents" + }, + { + "file": "ui/storybook/stories/tasks/chat-comments.stories.tsx", + "capabilities": [ + "tasks/discussion", + "runs-adapters/runs" + ], + "purpose": "Product/Chat & Comments", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "maps/feature-map/tasks.md", + "ui/src/components/CommentThread.tsx", + "ui/src/components/InlineEntitySelector.tsx", + "ui/src/components/IssueChatThread.tsx", + "ui/src/components/MarkdownBody.tsx", + "ui/src/components/MarkdownEditor.tsx", + "ui/src/components/RunChatSurface.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/tasks/chat-comments.stories.tsx" + ], + "fingerprint": "0b20b3e5915b5a5d0b11a8802a9441d63f8d551facdfdde6cfe58936e4cb18e2", + "stableId": "product-chat-comments", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Chat & Comments" + }, + { + "file": "ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx", + "capabilities": [ + "chat-channels/providers" + ], + "purpose": "Connections/Chat connection actions", + "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", + "sources": [ + "maps/feature-map/chat-channels.md", + "ui/src/pages/apps/Browse.tsx", + "ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx" + ], + "fingerprint": "c80c99044431f1cfe75e7b3da1b28d776595217d5bbda14aa82c78b0b272aab0", + "stableId": "connections-chat-connection-actions", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat connection actions" + }, + { + "file": "ui/storybook/stories/agent-chat/chat-entry.stories.tsx", + "capabilities": [ + "agent-chat/discovery", + "agent-chat/history" + ], + "purpose": "Design explorations/Chat entry", + "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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "ui/src/components/AgentChatPicker.tsx", + "ui/src/components/AgentIconPicker.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/SidebarAgentChats.tsx", + "ui/src/pages/AgentChat.tsx", + "ui/src/pages/AgentChats.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.tsx", + "ui/src/pages/IssueDetail.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/agent-chat-sidebar/AgentChatSidebarReview.tsx", + "ui/storybook/prototypes/agent-chat-sidebar/fixtures.ts", + "ui/storybook/prototypes/agent-chat/AgentChatPrototype.tsx", + "ui/storybook/prototypes/agent-chat/AgentChatSidebar.tsx", + "ui/storybook/prototypes/chat-entry/ChatEntryReview.tsx", + "ui/storybook/stories/agent-chat/chat-entry.stories.tsx" + ], + "fingerprint": "f85f60b17e8ac01056cd969e884877e58742cb627c6bf52470bc93a4583cef6c", + "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" + }, + { + "file": "ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx", + "capabilities": [ + "chat-channels/lifecycle" + ], + "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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/chat-channels.md", + "ui/src/components/Layout.tsx", + "ui/src/pages/apps/chat/ChatEndpointDetail.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx" + ], + "fingerprint": "5025a812c84d5700fea3907dd09f484ae3ded0d2f1cfc0f8e83f0fb5bc6d181e", + "stableId": "connections-chat-management", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat management" + }, + { + "file": "ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx", + "capabilities": [ + "recovery/reconnect" + ], + "purpose": "App/Connection recovery", + "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", + "sources": [ + "maps/feature-map/recovery.md", + "ui/src/components/CloudAccessGate.tsx", + "ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx" + ], + "fingerprint": "ce4f356d3f8296a2f6e3c6da8bb6b3a57866a0ad9c9e74850a76ff7fd9484b54", + "stableId": "app-connection-recovery", + "sidebarTitle": "Projects and execution/Recovery/App/Connection recovery" + }, + { + "file": "ui/storybook/stories/tasks/completed-activity.stories.tsx", + "capabilities": [ + "tasks/discussion", + "runs-adapters/runs" + ], + "purpose": "Tasks/Completed activity preview", + "status": "current", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "maps/feature-map/tasks.md", + "ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx", + "ui/src/components/task-chat/task-chat-model.ts", + "ui/storybook/prototypes/completed-activity/CompletedActivityPreview.tsx", + "ui/storybook/stories/tasks/completed-activity.stories.tsx" + ], + "fingerprint": "fca103da8fa4c7b8b3ec37b60891fca45e8a3848dd826554540a0463b02d5484", + "stableId": "tasks-completed-activity-preview", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Completed activity preview" + }, + { + "file": "ui/storybook/stories/tasks/composer-add-menu.stories.tsx", + "capabilities": [ + "tasks/create" + ], + "purpose": "Composer/Add menu", + "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", + "sources": [ + "maps/feature-map/tasks.md", + "ui/src/components/MobileBottomNav.tsx", + "ui/src/components/task-chat/TaskChatComposer.tsx", + "ui/src/components/task-chat/TaskChatComposerDock.tsx", + "ui/storybook/prototypes/composer-model-picker/fixtures.ts", + "ui/storybook/stories/tasks/composer-add-menu.stories.tsx" + ], + "fingerprint": "cad28d51256da8f82100cec8c051bb8c40af4477b6582341c9a18e2ad18ba612", + "stableId": "composer-add-menu", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Add menu" + }, + { + "file": "ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx", + "capabilities": [ + "tasks/create", + "runs-adapters/availability" + ], + "purpose": "Composer/Mobile assignee picker", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "maps/feature-map/tasks.md", + "ui/src/components/AgentAvatar.tsx", + "ui/src/components/task-chat/ComposerAddMenu.tsx", + "ui/src/components/task-chat/ComposerRunSettingsPicker.tsx", + "ui/src/components/task-chat/composer-run-settings.ts", + "ui/storybook/prototypes/composer-model-picker/ComposerRunSettingsLiveStory.tsx", + "ui/storybook/prototypes/composer-model-picker/fixtures.ts", + "ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx" + ], + "fingerprint": "4bb7efa59b0a58b1001920f345c16c29643b495b9b7dca2a955c57151d8e9702", + "stableId": "composer-mobile-assignee-picker", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker" + }, + { + "file": "ui/storybook/stories/tasks/composer-model-picker.stories.tsx", + "capabilities": [ + "tasks/create", + "runs-adapters/availability" + ], + "purpose": "Composer/Model and effort picker", + "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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "maps/feature-map/tasks.md", + "ui/src/components/AgentAvatar.tsx", + "ui/src/components/task-chat/ComposerAddMenu.tsx", + "ui/src/components/task-chat/ComposerRunSettingsPicker.tsx", + "ui/src/components/task-chat/composer-run-settings.ts", + "ui/storybook/prototypes/composer-model-picker/ComposerModelPickerPreview.tsx", + "ui/storybook/prototypes/composer-model-picker/ComposerRunSettingsLiveStory.tsx", + "ui/storybook/prototypes/composer-model-picker/fixtures.ts", + "ui/storybook/stories/tasks/composer-model-picker.stories.tsx" + ], + "fingerprint": "59842301f4280c58f5699a25bdc13f488515e67d4d601ca64c38fd6b87711154", + "stableId": "composer-model-and-effort-picker", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker" + }, + { + "file": "ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx", + "capabilities": [ + "questions-and-approvals/questions" + ], + "purpose": "Composer/Question flow", + "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", + "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", + "stableId": "composer-question-flow", + "sidebarTitle": "Tasks and conversations/Questions and approvals/Composer/Question flow" + }, + { + "file": "ui/storybook/stories/steering/composer-queued-messages.stories.tsx", + "capabilities": [ + "steering/queue", + "steering/edit-order-discard", + "steering/steer", + "steering/interrupt" + ], + "purpose": "Composer/Steering and queued messages", + "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", + "sources": [ + "maps/feature-map/steering.md", + "ui/src/components/task-chat/TaskChatComposer.tsx", + "ui/src/components/task-chat/TaskChatQueuedMessages.tsx", + "ui/storybook/prototypes/composer-model-picker/fixtures.ts", + "ui/storybook/stories/steering/composer-queued-messages.stories.tsx" + ], + "fingerprint": "a411b06048a06cacf97c10f29c761c0b13f526766078955f98420d4a1530d7a2", + "stableId": "composer-steering-and-queued-messages", + "sidebarTitle": "Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages" + }, + { + "file": "ui/storybook/stories/connection-setup/connection-instructions.stories.tsx", + "capabilities": [ + "connection-setup/credentials", + "agents/instructions" + ], + "purpose": "Design explorations/Connections/Agent instructions", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "maps/feature-map/connection-setup.md", + "ui/src/components/Layout.tsx", + "ui/src/features/connections/ConnectionInstructions.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/src/pages/apps/AppDetail.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/storybook/fixtures/connectionInstructions.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/connection-setup/connection-instructions.stories.tsx" + ], + "fingerprint": "2eea32a1604966eb6416b4381691f3b163a25bcd764bf739a9cd91d951d96b3e", + "stableId": "design-explorations-connections-agent-instructions", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions" + }, + { + "file": "ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx", + "capabilities": [ + "runs-adapters/validation", + "connection-setup/catalog" + ], + "purpose": "Connectors/Pool host", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/connection-setup.md", + "maps/feature-map/runs-adapters.md", + "ui/src/components/AppConnectionSidebar.tsx", + "ui/src/components/BreadcrumbBar.tsx", + "ui/src/pages/apps/AppDetail.tsx", + "ui/src/pages/apps/AppsConnect.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx", + "ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx" + ], + "fingerprint": "3cfe5fcb4efa0827661820f44dfb8f25c554655e224f1f04e0be8f6e37485f14", + "stableId": "connectors-pool-host", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/Connectors/Pool host" + }, + { + "file": "ui/storybook/stories/runs-adapters/connection-pools.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Connection pools", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/IssueMonitorBanner.tsx", + "ui/src/components/RunRetryDetails.tsx", + "ui/src/components/ai-connections/AiConnectionField.tsx", + "ui/src/components/ai-connections/AiConnectionPoolRunDetails.tsx", + "ui/src/components/task-chat/ComposerRunSettingsPicker.tsx", + "ui/src/components/task-chat/composer-run-settings.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/runs-adapters/connection-pools.stories.tsx" + ], + "fingerprint": "9f9dc8c60981e8cee31f8434602d0317cdf8ece7a9f56938cb0a7b96c986cc61", + "stableId": "ai-connections-connection-pools", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Connection pools" + }, + { + "file": "ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx", + "capabilities": [ + "questions-and-approvals/tool-review", + "connection-setup/task-request" + ], + "purpose": "Chat & Comments/Connection Reviews", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "maps/feature-map/questions-and-approvals.md", + "ui/src/components/IssueThreadInteractionCard.tsx", + "ui/src/components/TaskChatThread.tsx", + "ui/src/pages/apps/ReviewQueueCard.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx" + ], + "fingerprint": "749a735937db08e6c6798578b75db4e81a744f6762d2218d8e392fe9c36afad8", + "stableId": "chat-comments-connection-reviews", + "sidebarTitle": "Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews" + }, + { + "file": "ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx", + "capabilities": [ + "tasks/organize", + "questions-and-approvals/governance", + "budgets-costs/budgets", + "activity/activity" + ], + "purpose": "Product/Control Plane Surfaces", + "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", + "sources": [ + "maps/feature-map/activity.md", + "maps/feature-map/budgets-costs.md", + "maps/feature-map/questions-and-approvals.md", + "maps/feature-map/tasks.md", + "ui/src/components/ActivityRow.tsx", + "ui/src/components/ApprovalCard.tsx", + "ui/src/components/BudgetPolicyCard.tsx", + "ui/src/components/Identity.tsx", + "ui/src/components/IssueRow.tsx", + "ui/src/components/PriorityIcon.tsx", + "ui/src/components/StatusBadge.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx" + ], + "fingerprint": "121469a91e60dbb4e085512d86e3bd8a24b1590469d0f1321ea5e1a869df7da0", + "stableId": "product-control-plane-surfaces", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces" + }, + { + "file": "ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx", + "capabilities": [ + "budgets-costs/cost-reporting", + "budgets-costs/budgets" + ], + "purpose": "Costs/Subscriptions", + "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", + "sources": [ + "maps/feature-map/budgets-costs.md", + "ui/src/components/ToastViewport.tsx", + "ui/src/pages/Costs.tsx", + "ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx" + ], + "fingerprint": "5663cc1d142ce63bf2c5e2edc23c19ef6b6f45fdee12cc79ad9e20446a5be5a8", + "stableId": "costs-subscriptions", + "sidebarTitle": "Oversight and operation/Costs and budgets/Costs/Subscriptions" + }, + { + "file": "ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx", + "capabilities": [ + "secrets/runtime-delivery", + "recovery/diagnosis" + ], + "purpose": "Task credential access/01 Notice", + "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", + "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": "d4d4b6098d76fa827e6529a6f071948f1f4e6a15a337da4a85ffe9f0234d8565", + "stableId": "task-credential-access-01-notice", + "sidebarTitle": "Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice" + }, + { + "file": "ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx", + "capabilities": [ + "secrets/runtime-delivery", + "recovery/diagnosis" + ], + "purpose": "Task credential access/02 Task page", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/recovery.md", + "maps/feature-map/secrets.md", + "ui/src/components/Layout.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/IssueDetail.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx", + "ui/storybook/support/credential-access/CredentialTaskPage.tsx" + ], + "fingerprint": "4d1e29eb4684fd157b0792682cce990216c9fffe0fb7ae8347ced51257c9b17f", + "stableId": "task-credential-access-02-task-page", + "sidebarTitle": "Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page" + }, + { + "file": "ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx", + "capabilities": [ + "documents-artifacts/attachments" + ], + "purpose": "Components/CSV File Tab", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/task-side-panel/TaskAttachmentPanel.tsx", + "ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx", + "ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx" + ], + "fingerprint": "734738f2d9a747215f4a34baa7d448238fbfc2da7218c79b1eff8643aed6cefc", + "stableId": "components-csv-file-tab", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Components/CSV File Tab" + }, + { + "file": "ui/storybook/stories/activity/data-viz-misc.stories.tsx", + "capabilities": [ + "activity/overview" + ], + "purpose": "Product/Data Visualization & Misc", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/activity.md", + "ui/src/components/ActivityCharts.tsx", + "ui/src/components/AgentRunnerSelect.tsx", + "ui/src/components/AsciiArtAnimation.tsx", + "ui/src/components/CompanyPatternIcon.tsx", + "ui/src/components/EntityRow.tsx", + "ui/src/components/FilterBar.tsx", + "ui/src/components/KanbanBoard.tsx", + "ui/src/components/LiveRunWidget.tsx", + "ui/src/components/OnboardingWizard.tsx", + "ui/src/components/PackageFileTree.tsx", + "ui/src/components/PageSkeleton.tsx", + "ui/src/components/StatusBadge.tsx", + "ui/src/components/SwipeToArchive.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/activity/data-viz-misc.stories.tsx" + ], + "fingerprint": "c4144f4e6cb5faadfe9592acd71e586cb60db01bd3ef7533a37182c9c27d7d95", + "stableId": "product-data-visualization-misc", + "sidebarTitle": "Oversight and operation/Dashboards and audit trails/Product/Data Visualization & Misc" + }, + { + "file": "ui/storybook/stories/inbox-search/decision-card.stories.tsx", + "capabilities": [ + "inbox-search/decisions" + ], + "purpose": "Decisions/DecisionCard", + "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", + "sources": [ + "maps/feature-map/inbox-search.md", + "ui/src/components/DecisionCard.tsx", + "ui/storybook/stories/inbox-search/decision-card.stories.tsx" + ], + "fingerprint": "151c678aa5f7befc369d2def8df019b626c865f1c5b7bf91543dd4454395cb66", + "stableId": "decisions-decisioncard", + "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard" + }, + { + "file": "ui/storybook/stories/companies/decision-models/01-settings.stories.tsx", + "capabilities": [ + "companies/identity", + "budgets-costs/cost-reporting" + ], + "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", + "sources": [ + "maps/feature-map/budgets-costs.md", + "maps/feature-map/companies.md", + "ui/src/components/decision-models/DecisionModelSettings.tsx", + "ui/storybook/stories/companies/decision-models/01-settings.stories.tsx", + "ui/storybook/support/decision-models/fixtures.ts" + ], + "fingerprint": "26ac3209bb283a94e102e2328689aec2cd801680ede48cbfdf85f77ed3b2b647", + "stableId": "decision-models-01-settings", + "sidebarTitle": "Getting started and access/Companies and portability/Decision models/01 Settings" + }, + { + "file": "ui/storybook/stories/companies/decision-models/02-history.stories.tsx", + "capabilities": [ + "companies/identity", + "budgets-costs/cost-reporting" + ], + "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", + "sources": [ + "maps/feature-map/budgets-costs.md", + "maps/feature-map/companies.md", + "ui/src/components/decision-models/DecisionHistory.tsx", + "ui/storybook/stories/companies/decision-models/02-history.stories.tsx", + "ui/storybook/support/decision-models/fixtures.ts" + ], + "fingerprint": "bae133e089fe0a1f7a27436d63bcc6eb4e49612a6418bb0529d49dc3da54d79f", + "stableId": "decision-models-02-history", + "sidebarTitle": "Getting started and access/Companies and portability/Decision models/02 History" + }, + { + "file": "ui/storybook/stories/companies/decision-models/03-journey.stories.tsx", + "capabilities": [ + "companies/identity", + "budgets-costs/cost-reporting" + ], + "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", + "sources": [ + "maps/feature-map/budgets-costs.md", + "maps/feature-map/companies.md", + "ui/src/components/Layout.tsx", + "ui/src/pages/CompanySettings.tsx", + "ui/src/pages/Costs.tsx", + "ui/storybook/stories/companies/decision-models/03-journey.stories.tsx", + "ui/storybook/support/decision-models/fixtures.ts" + ], + "fingerprint": "9d5c8aa2027e900b4b5b52b602065dfe7c779e235b42f46a99dddb8037e4006a", + "stableId": "decision-models-03-journey", + "sidebarTitle": "Getting started and access/Companies and portability/Decision models/03 Journey" + }, + { + "file": "ui/storybook/stories/inbox-search/decisions-desk.stories.tsx", + "capabilities": [ + "inbox-search/decisions" + ], + "purpose": "Pages/Decisions Desk", + "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", + "sources": [ + "maps/feature-map/inbox-search.md", + "ui/src/components/AttentionQueueRow.tsx", + "ui/src/pages/DecisionQueuePage.tsx", + "ui/src/pages/WhatNeedsMe.tsx", + "ui/storybook/stories/inbox-search/decisions-desk.stories.tsx" + ], + "fingerprint": "614be5300540b7c3c8942a8a043e005d0f92e0a021819ade6ad291526b923566", + "stableId": "pages-decisions-desk", + "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk" + }, + { + "file": "ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx", + "capabilities": [ + "instance-operations/server-service" + ], + "purpose": "Product/Dev Ops Surfaces", + "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", + "sources": [ + "maps/feature-map/instance-operations.md", + "ui/src/components/DevRestartBanner.tsx", + "ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx" + ], + "fingerprint": "e936abb4c886157de2f3772383b4062734e0b3a8c7dc64bb42331eef64cd6456", + "stableId": "product-dev-ops-surfaces", + "sidebarTitle": "Oversight and operation/Instance operations/Product/Dev Ops Surfaces" + }, + { + "file": "ui/storybook/stories/tasks/dialogs-modals.stories.tsx", + "capabilities": [ + "tasks/create", + "agents/hire", + "projects/lifecycle", + "goals/hierarchy" + ], + "purpose": "Product/Dialogs & Modals", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "maps/feature-map/goals.md", + "maps/feature-map/projects.md", + "maps/feature-map/tasks.md", + "ui/src/components/DocumentDiffModal.tsx", + "ui/src/components/ExecutionWorkspaceCloseDialog.tsx", + "ui/src/components/ImageGalleryModal.tsx", + "ui/src/components/NewAgentDialog.tsx", + "ui/src/components/NewGoalDialog.tsx", + "ui/src/components/NewIssueDialog.tsx", + "ui/src/components/NewProjectDialog.tsx", + "ui/src/components/PathInstructionsModal.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/tasks/dialogs-modals.stories.tsx" + ], + "fingerprint": "d5522df3653b5ee8258374d3406f2680be64621145e7e2ef56944081effbb151", + "stableId": "product-dialogs-modals", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals" + }, + { + "file": "ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx", + "capabilities": [ + "documents-artifacts/annotations" + ], + "purpose": "Product/Documents/Annotations", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/DocumentAnnotationLayer.tsx", + "ui/src/components/DocumentAnnotationPanel.tsx", + "ui/src/components/IssueDocumentAnnotations.tsx", + "ui/src/components/IssueDocumentsSection.tsx", + "ui/src/components/MarkdownBody.tsx", + "ui/src/components/MarkdownEditor.tsx", + "ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx" + ], + "fingerprint": "37f01db23f298992b973fed56fd439ae0a0586bfc703af55dcf9fa69f14b86de", + "stableId": "product-documents-annotations", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Product/Documents/Annotations" + }, + { + "file": "ui/storybook/stories/documents-artifacts/document-comments.stories.tsx", + "capabilities": [ + "documents-artifacts/annotations" + ], + "purpose": "Product/Document Comments", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/DocumentAnnotationLayer.tsx", + "ui/src/components/DocumentAnnotationPanel.tsx", + "ui/storybook/stories/documents-artifacts/document-comments.stories.tsx" + ], + "fingerprint": "1ff3445ed22893256cecf44c00c5f4714ae573e4d26351388fb672efe8726df4", + "stableId": "product-document-comments", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Product/Document Comments" + }, + { + "file": "ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx", + "capabilities": [ + "runs-adapters/validation", + "gateways-profiles/client-connect" + ], + "purpose": "Assistant connections/Dot Runner", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/gateways-profiles.md", + "maps/feature-map/runs-adapters.md", + "ui/src/components/DotRunnerConnection.tsx", + "ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx" + ], + "fingerprint": "5a292d4238bf360ae27802109832511728cd60595293fedf8d45fdd0a8232ee7", + "stableId": "assistant-connections-dot-runner", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/Assistant connections/Dot Runner" + }, + { + "file": "ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx", + "capabilities": [ + "connection-setup/credentials", + "connection-setup/failure" + ], + "purpose": "Apps/Enterpret connector", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/pages/apps/AppsConnect.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/src/pages/apps/app-detail/AdvancedPanel.tsx", + "ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx" + ], + "fingerprint": "4b82e0db0a71ad569c10f32553084e884a89cb58eac33782ca07ae272e7411c1", + "stableId": "apps-enterpret-connector", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Enterpret connector" + }, + { + "file": "ui/storybook/stories/secrets/environment-variables-editor.stories.tsx", + "capabilities": [ + "secrets/runtime-delivery" + ], + "purpose": "Product/Environment Variables Editor", + "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", + "sources": [ + "maps/feature-map/secrets.md", + "ui/src/components/environment-variables-editor/index.tsx", + "ui/storybook/stories/secrets/environment-variables-editor.stories.tsx" + ], + "fingerprint": "f41ed501dc34f19a8ef6071f1e7e563e8ac60769e6a2930dcfd8cde15d6551d0", + "stableId": "product-environment-variables-editor", + "sidebarTitle": "Apps, channels, and extensions/Secrets and proposals/Product/Environment Variables Editor" + }, + { + "file": "ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx", + "capabilities": [ + "recovery/diagnosis" + ], + "purpose": "Task chat/Recovery notice", + "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", + "sources": [ + "maps/feature-map/recovery.md", + "ui/src/components/ExecutionBlockerNotice.tsx", + "ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx" + ], + "fingerprint": "19fa3d95095bbcd67c5306d6ad3c8c00ae526bab9d8cbc49e69ca36c15a873c2", + "stableId": "task-chat-recovery-notice", + "sidebarTitle": "Projects and execution/Recovery/Task chat/Recovery notice" + }, + { + "file": "ui/storybook/stories/recovery/execution-recovery.stories.tsx", + "capabilities": [ + "recovery/diagnosis" + ], + "purpose": "Tasks/Execution recovery", + "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", + "sources": [ + "maps/feature-map/recovery.md", + "ui/src/components/ActiveAgentsPanel.tsx", + "ui/src/components/IssueRow.tsx", + "ui/src/components/task-chat/TaskChatLiveRunPill.tsx", + "ui/src/components/task-chat/TaskChatRunnerTurn.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/recovery/execution-recovery.stories.tsx" + ], + "fingerprint": "003b5d7b2f8d0baea06402776157854f6b55d06bfd4d6223300413c284a22fd2", + "stableId": "tasks-execution-recovery", + "sidebarTitle": "Projects and execution/Recovery/Tasks/Execution recovery" + }, + { + "file": "ui/storybook/stories/agents/external-agent-invite/components.stories.tsx", + "capabilities": [ + "agents/hire", + "access/invitations" + ], + "purpose": "Onboarding/External agent invitation/Components", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/access.md", + "maps/feature-map/agents.md", + "ui/src/components/new-agent/ExternalAgentInviteContent.tsx", + "ui/storybook/stories/agents/external-agent-invite/components.stories.tsx" + ], + "fingerprint": "f18684874d9cdc89a26e987c2420389f0da42daa4edb473b682b96b92db9e816", + "stableId": "onboarding-external-agent-invitation-components", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components" + }, + { + "file": "ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx", + "capabilities": [ + "agents/hire", + "access/invitations" + ], + "purpose": "Onboarding/External agent invitation/Journeys", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/access.md", + "maps/feature-map/agents.md", + "ui/src/components/AnimatedDialogContent.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/new-agent/AgentBasicsDialog.tsx", + "ui/src/components/new-agent/ExternalAgentInviteContent.tsx", + "ui/src/pages/Agents.tsx", + "ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx", + "ui/storybook/support/external-agent-invite/ExternalAgentInvitePreview.tsx", + "ui/storybook/support/external-agent-invite/fixtures.ts" + ], + "fingerprint": "480c70c3a0be71a3085cd6e95e98c9eeef29fe5d83ad6b2ed6153d78d6137e88", + "stableId": "onboarding-external-agent-invitation-journeys", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys" + }, + { + "file": "ui/storybook/stories/tasks/external-objects.stories.tsx", + "capabilities": [ + "tasks/properties" + ], + "purpose": "Foundations/External Objects", + "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", + "sources": [ + "maps/feature-map/tasks.md", + "ui/src/components/ExternalObjectPill.tsx", + "ui/src/components/ExternalObjectStatusIcon.tsx", + "ui/src/components/ExternalObjectStatusSummary.tsx", + "ui/src/components/IssueFiltersPopover.tsx", + "ui/src/components/IssueProperties.tsx", + "ui/src/components/IssueRelatedWorkPanel.tsx", + "ui/src/components/IssueRow.tsx", + "ui/src/components/MarkdownBody.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/tasks/external-objects.stories.tsx" + ], + "fingerprint": "5654b1d1a51fe8d00c3546452e6e2a02bda0be49b014a2bad2a4227c3f1ce685", + "stableId": "foundations-external-objects", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/External Objects" + }, + { + "file": "ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx", + "capabilities": [ + "companies/identity", + "budgets-costs/cost-reporting" + ], + "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", + "sources": [ + "maps/feature-map/budgets-costs.md", + "maps/feature-map/companies.md", + "ui/src/components/fast-responses/FastResponseSettings.tsx", + "ui/storybook/support/decision-models/fixtures.ts", + "ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx" + ], + "fingerprint": "1610fca918abcbf89a5cf901ae36217df841169efcc21bd91db735bb0ea306bd", + "stableId": "fast-responses-01-settings", + "sidebarTitle": "Getting started and access/Companies and portability/Fast responses/01 Settings" + }, + { + "file": "ui/storybook/stories/companies/fast-responses/02-history.stories.tsx", + "capabilities": [ + "companies/identity", + "budgets-costs/cost-reporting" + ], + "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", + "sources": [ + "maps/feature-map/budgets-costs.md", + "maps/feature-map/companies.md", + "ui/src/components/decision-models/DecisionHistory.tsx", + "ui/storybook/support/decision-models/fixtures.ts", + "ui/storybook/stories/companies/fast-responses/02-history.stories.tsx" + ], + "fingerprint": "a2ed599851817c86c4b03be10c9083e365401be922f731260f1c718e0975c803", + "stableId": "fast-responses-02-history", + "sidebarTitle": "Getting started and access/Companies and portability/Fast responses/02 History" + }, + { + "file": "ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx", + "capabilities": [ + "documents-artifacts/attachments" + ], + "purpose": "Components/File Viewer Sheet", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/FileViewerSheet.tsx", + "ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx" + ], + "fingerprint": "8626806337c9c664e11cee4587b80e068dfd3e6259e8c8fc2d3d1648b68abf84", + "stableId": "components-file-viewer-sheet", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Components/File Viewer Sheet" + }, + { + "file": "ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx", + "capabilities": [ + "connection-setup/credentials", + "routines/triggers" + ], + "purpose": "PR reviews/Fireflies and app 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", + "sources": [ + "maps/feature-map/connection-setup.md", + "maps/feature-map/routines.md", + "ui/src/components/Layout.tsx", + "ui/src/components/RoutineTriggerCard.tsx", + "ui/src/components/routine-sections/context.tsx", + "ui/src/components/routine-sections/editable-sections.production.tsx", + "ui/src/components/routine-triggers/TriggerWizard.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/src/pages/RoutineDetail.tsx", + "ui/src/pages/Routines.tsx", + "ui/src/pages/apps/AppLogo.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/src/pages/apps/app-detail/PermissionsPanel.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/routineWebhooks.tsx", + "ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx" + ], + "fingerprint": "d7898061478285bbc6fa9ee5152bdb840ca3d329590a3b35573e0632282278e5", + "stableId": "pr-reviews-fireflies-and-app-webhooks", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks" + }, + { + "file": "ui/storybook/stories/skills/fork-skill-flow.stories.tsx", + "capabilities": [ + "skills/authoring" + ], + "purpose": "Skill Studio/EditACopy", + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/components/skill-studio/AgentsUsingSkillDialog.tsx", + "ui/src/components/skill-studio/ForkSkillDialog.tsx", + "ui/src/components/skill-studio/SkillProvenance.tsx", + "ui/storybook/stories/skills/fork-skill-flow.stories.tsx" + ], + "fingerprint": "7cd95e52a451f78371dc2c5793679c14b28f1fb486dcb2ed5f8b1f4e041267a8", + "stableId": "skill-studio-editacopy", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/EditACopy" + }, + { + "file": "ui/storybook/stories/foundations/forms-editors.stories.tsx", + "capabilities": [], + "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", + "sources": [ + "ui/src/components/ExecutionParticipantPicker.tsx", + "ui/src/components/FoldCurtain.tsx", + "ui/src/components/InlineEditor.tsx", + "ui/src/components/InlineEntitySelector.tsx", + "ui/src/components/JsonSchemaForm.tsx", + "ui/src/components/MarkdownBody.tsx", + "ui/src/components/MarkdownEditor.tsx", + "ui/src/components/ReportsToPicker.tsx", + "ui/src/components/RoutineRunVariablesDialog.tsx", + "ui/src/components/RoutineVariablesEditor.tsx", + "ui/src/components/ScheduleEditor.tsx", + "ui/src/components/environment-variables-editor/index.tsx", + "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" + ], + "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", + "fingerprint": "2ffc131b9725c748a223fe4ddc8f9d31242e6d96e0d2feb3ab91563a89dea120", + "stableId": "components-forms-editors", + "sidebarTitle": "Foundations/Components/Forms & Editors" + }, + { + "file": "ui/storybook/stories/foundations/foundations.stories.tsx", + "capabilities": [], + "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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "ui/src/components/ui/badge.tsx", + "ui/src/components/ui/button.tsx", + "ui/src/components/ui/card.tsx", + "ui/src/components/ui/checkbox.tsx", + "ui/src/components/ui/dialog.tsx", + "ui/src/components/ui/input.tsx", + "ui/src/components/ui/label.tsx", + "ui/src/components/ui/popover.tsx", + "ui/src/components/ui/select.tsx", + "ui/src/components/ui/separator.tsx", + "ui/src/components/ui/tabs.tsx", + "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" + ], + "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", + "fingerprint": "1a7001bb2903b74643281a25d3b4e29ecb4a14a10bdf69e06d23cc25fce45549", + "stableId": "foundations-primitive-matrix", + "sidebarTitle": "Foundations/Primitive Matrix" + }, + { + "file": "ui/storybook/stories/skills/frontmatter-panel.stories.tsx", + "capabilities": [ + "skills/authoring" + ], + "purpose": "Product/Skill Studio · FrontmatterPanel", + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/components/FrontmatterPanel.tsx", + "ui/storybook/stories/skills/frontmatter-panel.stories.tsx" + ], + "fingerprint": "e7b2ffabdadc43d7fa2f564465792539d25f15da54fb4f7fec31cc4a2f0aec76", + "stableId": "product-skill-studio-·-frontmatterpanel", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Product/Skill Studio · FrontmatterPanel" + }, + { + "file": "ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx", + "capabilities": [ + "gateways-profiles/gateway" + ], + "purpose": "Tools/Gateways (PAP-11182)", + "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", + "sources": [ + "maps/feature-map/gateways-profiles.md", + "ui/src/pages/tools/GatewaysTab.tsx", + "ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx" + ], + "fingerprint": "c4e0e6770f0ed5d6cad1805717b38b9ab6a96e1c885a2013af979556dd45e8ae", + "stableId": "tools-gateways-pap-11182", + "sidebarTitle": "Apps, channels, and extensions/Tool gateways and access profiles/Tools/Gateways (PAP-11182)" + }, + { + "file": "ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx", + "capabilities": [ + "chat-channels/providers", + "chat-channels/conversation" + ], + "purpose": "Connectors/GitHub bot 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. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/chat-channels.md", + "ui/src/components/AgentRunnerSelect.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/OnboardingWizard.tsx", + "ui/src/pages/apps/chat/ChatConversationList.tsx", + "ui/src/pages/apps/chat/ChatEndpointDetail.tsx", + "ui/src/pages/apps/chat/GitHubAppIdentity.tsx", + "ui/src/pages/apps/chat/GitHubBotConfiguration.tsx", + "ui/src/pages/apps/chat/GitHubBotManagement.tsx", + "ui/src/pages/apps/chat/GitHubChatSetup.tsx", + "ui/src/pages/apps/chat/GitHubConnectionComplete.tsx", + "ui/storybook/fixtures/onboardingDraft.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx", + "ui/storybook/support/github-bot-management/fixture-api.tsx", + "ui/storybook/support/github-bot-management/fixtures.ts" + ], + "fingerprint": "cfcaa3fd3774837c872d32299f205d636f550cedc6d1ef2ed377550a906019c4", + "stableId": "connectors-github-bot-management", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management" + }, + { + "file": "ui/storybook/stories/chat-channels/github-chat.stories.tsx", + "capabilities": [ + "chat-channels/providers", + "chat-channels/conversation" + ], + "purpose": "Historical/GitHub chat prototypes", + "status": "historical", + "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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/chat-channels.md", + "ui/src/components/GitHubAgentTrustWarning.tsx", + "ui/src/components/SetupWizard.tsx", + "ui/src/pages/apps/chat/GitHubSetupPrompt.tsx", + "ui/storybook/prototypes/github-chat/AccessEditor.tsx", + "ui/storybook/prototypes/github-chat/GitHubChatPreview.tsx", + "ui/storybook/prototypes/github-chat/fixtures.ts", + "ui/storybook/stories/chat-channels/github-chat.stories.tsx" + ], + "replacement": "ui/storybook/stories/chat-channels/github-connections.stories.tsx", + "fingerprint": "acb56c908e107266128dd0d817162007a0a7ec75b24fa059714cee490e4ef642", + "stableId": "historical-github-chat-prototypes", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes" + }, + { + "file": "ui/storybook/stories/chat-channels/github-connections.stories.tsx", + "capabilities": [ + "chat-channels/providers", + "connection-setup/channel-setup" + ], + "purpose": "Connections/GitHub and Code Review Bot", + "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", + "sources": [ + "maps/feature-map/chat-channels.md", + "maps/feature-map/connection-setup.md", + "ui/src/components/SetupWizard.tsx", + "ui/src/pages/apps/AppsConnect.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/src/pages/apps/chat/ChatEndpointSetup.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/chat-channels/github-connections.stories.tsx" + ], + "fingerprint": "737355c3a9a9e1fe69b606edf543d0e7f0b94b7332a344a0f077c4cccfb544b9", + "stableId": "connections-github-and-code-review-bot", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot" + }, + { + "file": "ui/storybook/stories/skills/github-skills-journey.stories.tsx", + "capabilities": [ + "skills/versions", + "skills/discovery" + ], + "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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/components/Layout.tsx", + "ui/src/pages/CompanySkills.tsx", + "ui/src/pages/SkillSources.tsx", + "ui/src/pages/SkillStudio.tsx", + "ui/storybook/fixtures/githubSkillSources.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/skills/github-skills-journey.stories.tsx" + ], + "fingerprint": "dcb8e4ce80ddc71f25542b9d84cdf1ce20e552da2848404e62d61f0687e3fc3f", + "stableId": "skills-github-sync-journey", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey" + }, + { + "file": "ui/storybook/stories/skills/github-synced-skills.stories.tsx", + "capabilities": [ + "skills/versions", + "skills/discovery" + ], + "purpose": "Skills/GitHub synced skills", + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/pages/SkillSources.tsx", + "ui/storybook/fixtures/githubSkillSources.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/skills/github-synced-skills.stories.tsx" + ], + "fingerprint": "f87c8ee2574a097ef4e4828589618ad196f170fb652db022bc7488b3fb7971e2", + "stableId": "skills-github-synced-skills", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills" + }, + { + "file": "ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx", + "capabilities": [ + "documents-artifacts/attachments", + "workspaces/files-git" + ], + "purpose": "HTML artifacts/01 Preview", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "maps/feature-map/workspaces.md", + "ui/src/components/task-side-panel/TaskAttachmentPanel.tsx", + "ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx", + "ui/storybook/support/html-artifacts/fixtures.ts" + ], + "fingerprint": "7289b022aeb669441639942cb709e07a1f03f1cf4e0ca5db3672c654a50da92a", + "stableId": "html-artifacts-01-preview", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview" + }, + { + "file": "ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx", + "capabilities": [ + "documents-artifacts/attachments", + "workspaces/files-git" + ], + "purpose": "HTML artifacts/02 Workspace", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "maps/feature-map/workspaces.md", + "ui/src/components/FileViewerSheet.tsx", + "ui/src/components/task-side-panel/TaskWorkspaceFilePanel.tsx", + "ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx", + "ui/storybook/support/html-artifacts/fixtures.ts" + ], + "fingerprint": "2463cc23aa39bca80133e8b19bb4899f9dfbab7272747796db7855da51642ec2", + "stableId": "html-artifacts-02-workspace", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace" + }, + { + "file": "ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx", + "capabilities": [ + "documents-artifacts/attachments", + "workspaces/files-git" + ], + "purpose": "HTML artifacts/03 Task 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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "maps/feature-map/workspaces.md", + "ui/src/components/Layout.tsx", + "ui/src/components/artifacts/IssueArtifactCard.tsx", + "ui/src/components/task-chat/TaskChatBubble.tsx", + "ui/src/components/task-side-panel/TaskSidePanel.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx", + "ui/storybook/support/html-artifacts/fixtures.ts" + ], + "fingerprint": "d2b3ecd2b4490ca3bec6c8dfacad4c02b794f78fd4d348c6c0fd165fb8928f33", + "stableId": "html-artifacts-03-task-journey", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey" + }, + { + "file": "ui/storybook/stories/chat-channels/imessage-photon.stories.tsx", + "capabilities": [ + "chat-channels/conversation" + ], + "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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/chat-channels.md", + "ui/src/components/task-chat/TaskChatBubble.tsx", + "ui/src/components/task-chat/task-chat-model.ts", + "ui/src/pages/apps/Browse.tsx", + "ui/src/pages/apps/chat/ChatEndpointDetail.tsx", + "ui/src/pages/apps/chat/ChatEndpointSetup.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/chat-channels/imessage-photon.stories.tsx" + ], + "fingerprint": "e808e8533ef84eaa5f0c49144f7fa23554a0956217177a25fe7386ddaf4c9ffc", + "stableId": "connections-imessage-photon", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/iMessage Photon" + }, + { + "file": "ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx", + "capabilities": [ + "connection-setup/task-request" + ], + "purpose": "Connections/In-task connections", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/components/task-chat/TaskChatComposer.tsx", + "ui/src/components/task-chat/TaskChatInteractionCard.tsx", + "ui/src/features/connections/ConnectionIntentInteractionBody.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx" + ], + "fingerprint": "23c4fa7c1255dd2110e51fcd9cc78942c716ea1adc3bd59ffd8cabf7830930a3", + "stableId": "connections-in-task-connections", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Connections/In-task connections" + }, + { + "file": "ui/storybook/stories/steering/interrupt-handoff.stories.tsx", + "capabilities": [ + "steering/interrupt" + ], + "purpose": "Surfaces/Interrupt Handoff", + "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", + "sources": [ + "maps/feature-map/steering.md", + "ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx", + "ui/storybook/stories/steering/interrupt-handoff.stories.tsx" + ], + "fingerprint": "dbfb5b79928aefbc232eb6abbb17325aa6e5e0002c32bda062b529d2f486faab", + "stableId": "surfaces-interrupt-handoff", + "sidebarTitle": "Tasks and conversations/Steering and queued messages/Surfaces/Interrupt Handoff" + }, + { + "file": "ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx", + "capabilities": [ + "recovery/diagnosis", + "task-coordination/dependencies" + ], + "purpose": "Product/Issue/Blocked notice", + "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", + "sources": [ + "maps/feature-map/recovery.md", + "maps/feature-map/task-coordination.md", + "ui/src/components/IssueBlockedNotice.tsx", + "ui/src/components/task-chat/TaskChatBlockerLinks.tsx", + "ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx" + ], + "fingerprint": "4eb78b4e772567d657339435dc650e9b59c2347792cf764c7b376d7f631fe4de", + "stableId": "product-issue-blocked-notice", + "sidebarTitle": "Projects and execution/Recovery/Product/Issue/Blocked notice" + }, + { + "file": "ui/storybook/stories/tasks/issue-management.stories.tsx", + "capabilities": [ + "tasks/properties", + "tasks/organize" + ], + "purpose": "Product/Issue 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", + "sources": [ + "maps/feature-map/tasks.md", + "ui/src/components/Identity.tsx", + "ui/src/components/IssueColumns.tsx", + "ui/src/components/IssueContinuationHandoff.tsx", + "ui/src/components/IssueDocumentsSection.tsx", + "ui/src/components/IssueFiltersPopover.tsx", + "ui/src/components/IssueGroupHeader.tsx", + "ui/src/components/IssueLinkQuicklook.tsx", + "ui/src/components/IssueProperties.tsx", + "ui/src/components/IssueRunLedger.tsx", + "ui/src/components/IssueWorkspaceCard.tsx", + "ui/src/components/IssuesList.tsx", + "ui/src/components/IssuesQuicklook.tsx", + "ui/src/components/PriorityIcon.tsx", + "ui/src/components/StatusBadge.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/tasks/issue-management.stories.tsx" + ], + "fingerprint": "0692ea26dbbe757bb070183a445b72c08d0431cd3f008d77b57682ee099a7cc2", + "stableId": "product-issue-management", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Issue Management" + }, + { + "file": "ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx", + "capabilities": [ + "questions-and-approvals/plan-review", + "task-coordination/delegation" + ], + "purpose": "Issue Detail/Plan Decompositions", + "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", + "sources": [ + "maps/feature-map/questions-and-approvals.md", + "maps/feature-map/task-coordination.md", + "ui/src/components/IssuePlanDecompositionsSection.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx" + ], + "fingerprint": "b2a44ee40bf7e673563ad6e164e3476306782a9ea74e132fa5330eb78acb82e0", + "stableId": "issue-detail-plan-decompositions", + "sidebarTitle": "Tasks and conversations/Questions and approvals/Issue Detail/Plan Decompositions" + }, + { + "file": "ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx", + "capabilities": [ + "task-coordination/signoff" + ], + "purpose": "Product/Decisions/Stalled review actions", + "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", + "sources": [ + "maps/feature-map/task-coordination.md", + "ui/src/components/AttentionQueueRow.tsx", + "ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx" + ], + "fingerprint": "3c5317b38030ea75a8d57284af6d3ca2f0258dce52c694adc7b36fd41f6f875f", + "stableId": "product-decisions-stalled-review-actions", + "sidebarTitle": "Tasks and conversations/Delegation, dependencies, and signoff/Product/Decisions/Stalled review actions" + }, + { + "file": "ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx", + "capabilities": [ + "questions-and-approvals/questions", + "questions-and-approvals/confirmations", + "questions-and-approvals/audience" + ], + "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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/questions-and-approvals.md", + "ui/src/components/IssueChatThread.tsx", + "ui/src/components/IssueThreadInteractionCard.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx" + ], + "fingerprint": "b50484bfb74560001734590d7e7519fd6138f8f24c19afd4a26fdc99593cdb05", + "stableId": "chat-comments-issue-thread-interactions", + "sidebarTitle": "Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions" + }, + { + "file": "ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Provider routing/07 Subscription sign-in", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/AdapterLoginChrome.tsx", + "ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx" + ], + "fingerprint": "8f265894f6b0067637f0a0356043a0d76179d81764020f9a78537ecc80e3a72a", + "stableId": "ai-connections-provider-routing-07-subscription-sign-in", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/07 Subscription sign-in" + }, + { + "file": "ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx", + "capabilities": [ + "connection-setup/handoff" + ], + "purpose": "Apps/Managed Cloud OAuth handoff", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/pages/apps/PaperclipCloudOAuthHandoff.tsx", + "ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx" + ], + "fingerprint": "990d6bc169b5150f28c7e07eb8e9acb13056e4d71459217c36a637c5ef256d15", + "stableId": "apps-managed-cloud-oauth-handoff", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Managed Cloud OAuth handoff" + }, + { + "file": "ui/storybook/stories/connection-setup/memory-connectors.stories.tsx", + "capabilities": [ + "connection-setup/credentials", + "agents/instructions" + ], + "purpose": "Apps/Connections/Memory", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "maps/feature-map/connection-setup.md", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/src/pages/InstanceExperimentalSettings.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/storybook/stories/connection-setup/memory-connectors.stories.tsx" + ], + "fingerprint": "3a912f949b397f77b2ab4057414e9b4e9d1e40d5502724bebf5f7e210a32fd4f", + "stableId": "apps-connections-memory", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Connections/Memory" + }, + { + "file": "ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx", + "capabilities": [ + "tasks/create" + ], + "purpose": "Components/Entity pickers/Mobile", + "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", + "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", + "stableId": "components-entity-pickers-mobile", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile" + }, + { + "file": "ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx", + "capabilities": [ + "navigation-preferences/navigation" + ], + "purpose": "Prototypes/Task detail/Mobile tabs", + "status": "proposed", + "implementation": "prototype", + "boundary": "fixtures", + "notes": "Local composition retained for its original design purpose; full production parity remains unreviewed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/navigation-preferences.md", + "ui/src/components/side-panel/index.ts", + "ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx" + ], + "fingerprint": "f4ca5d157e5d943470084559cd557a95f80e3d20348e972bb3fa8df93d339bf6", + "stableId": "prototypes-task-detail-mobile-tabs", + "sidebarTitle": "Oversight and operation/Navigation, profile, and announcements/Prototypes/Task detail/Mobile tabs" + }, + { + "file": "ui/storybook/stories/recovery/model-capacity-retry.stories.tsx", + "capabilities": [ + "recovery/bounded-continuation" + ], + "purpose": "Tasks/Model capacity retry", + "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", + "sources": [ + "maps/feature-map/recovery.md", + "ui/src/components/IssueScheduledRetryCard.tsx", + "ui/storybook/stories/recovery/model-capacity-retry.stories.tsx" + ], + "fingerprint": "4f7421c0267fee71b9de5e38916ab86c66a2c69caa950730751c031a86503b4f", + "stableId": "tasks-model-capacity-retry", + "sidebarTitle": "Projects and execution/Recovery/Tasks/Model capacity retry" + }, + { + "file": "ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx", + "capabilities": [ + "task-coordination/tree-control" + ], + "purpose": "Product/Issue Monitor surfaces", + "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", + "sources": [ + "maps/feature-map/task-coordination.md", + "ui/src/components/IssueMonitorBanner.tsx", + "ui/src/components/IssueProperties.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx" + ], + "fingerprint": "2a9a823ce7e27a2e49d80bafc21394af1d34a396d53299b89e0d0c7fe4ae60f6", + "stableId": "product-issue-monitor-surfaces", + "sidebarTitle": "Tasks and conversations/Delegation, dependencies, and signoff/Product/Issue Monitor surfaces" + }, + { + "file": "ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx", + "capabilities": [ + "navigation-preferences/navigation" + ], + "purpose": "Product/Navigation & Layout", + "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", + "sources": [ + "maps/feature-map/navigation-preferences.md", + "ui/src/components/BreadcrumbBar.tsx", + "ui/src/components/CommandPalette.tsx", + "ui/src/components/CompanySwitcher.tsx", + "ui/src/components/KeyboardShortcutsCheatsheet.tsx", + "ui/src/components/MobileBottomNav.tsx", + "ui/src/components/PageTabBar.tsx", + "ui/src/components/Sidebar.tsx", + "ui/src/components/SidebarAccountMenu.tsx", + "ui/src/components/SidebarCompanyMenu.tsx", + "ui/src/components/StatusBadge.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx" + ], + "fingerprint": "80b5d40676ba4a9e37936b8481dd282e1e468c6e7b256cc8687a0a712e4d3dfe", + "stableId": "product-navigation-layout", + "sidebarTitle": "Oversight and operation/Navigation, profile, and announcements/Product/Navigation & Layout" + }, + { + "file": "ui/storybook/stories/agents/new-agent-onboarding.stories.tsx", + "capabilities": [ + "agents/hire" + ], + "purpose": "Onboarding/New agent", + "status": "historical", + "implementation": "prototype", + "boundary": "fixtures", + "notes": "Retained local wizard predates production ui/src/components/new-agent flow; shared fields do not make the wizard current. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/components/AdapterLoginChrome.tsx", + "ui/src/components/AgentConfigForm.tsx", + "ui/src/components/AgentRunnerSelect.tsx", + "ui/src/components/NewIssueDialog.tsx", + "ui/src/components/RuntimeTestCard.tsx", + "ui/src/components/agent-config-primitives.tsx", + "ui/src/components/new-agent/AgentBasicsDialog.tsx", + "ui/src/components/onboarding/CredentialModeLink.tsx", + "ui/src/components/onboarding/FooterNav.tsx", + "ui/src/components/onboarding/ModelSourceTiles.tsx", + "ui/src/components/onboarding/OnboardingPrimitives.tsx", + "ui/src/components/onboarding/PillGuy.tsx", + "ui/src/components/onboarding/onboarding-motion.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/NewAgentWizard.tsx", + "ui/storybook/prototypes/ProviderConnectionPreview.tsx", + "ui/storybook/prototypes/RuntimeTestCard.tsx", + "ui/storybook/prototypes/new-agent-fixtures.ts", + "ui/storybook/stories/agents/new-agent-onboarding.stories.tsx" + ], + "replacement": "ui/storybook/stories/agents/agent-persona-pages.stories.tsx", + "fingerprint": "0d78c2518fb75d3c079684572bdd4b2c288f92f58051e3cd66f2aa60e22c5cb1", + "stableId": "onboarding-new-agent", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent" + }, + { + "file": "ui/storybook/stories/projects/new-project-repos.stories.tsx", + "capabilities": [ + "projects/repositories" + ], + "purpose": "Proposals/Project repos/New project", + "status": "proposed", + "implementation": "mixed", + "boundary": "fixtures", + "notes": "RepositoryEditor is shipped, but NewProjectPrototype composes its own creation flow. Preserve proposal until route parity is checked.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/projects.md", + "ui/src/components/RepositoryEditor.tsx", + "ui/src/components/SearchableSelect.tsx", + "ui/src/components/icons/github-icon.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/storybook/prototypes/project-repos/ProjectReposPrototype.tsx", + "ui/storybook/prototypes/project-repos/fixtures.ts", + "ui/storybook/stories/projects/new-project-repos.stories.tsx" + ], + "fingerprint": "bf8bd88964cbf20a95449a10485d2aaf074cfe3f88456144332b193c2920be4b", + "stableId": "proposals-project-repos-new-project", + "sidebarTitle": "Projects and execution/Projects and repositories/Proposals/Project repos/New project" + }, + { + "file": "ui/storybook/stories/tasks/new-task-composer.stories.tsx", + "capabilities": [ + "tasks/create" + ], + "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.", + "reviewedAt": "2026-10-12", + "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" + ], + "fingerprint": "5fd37958b2f864e0f96e881540736c48f9884a0d4bf48200dea0fd0468c43a6d", + "stableId": "composer-new-task", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Composer/New task" + }, + { + "file": "ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx", + "capabilities": [ + "connection-setup/credentials", + "connection-setup/failure" + ], + "purpose": "Apps/Notion MCP connect flow (PAP-16650)", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/pages/apps/AppLogo.tsx", + "ui/src/pages/apps/AppsConnect.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/src/pages/apps/app-detail/AdvancedPanel.tsx", + "ui/src/pages/apps/app-detail/IdentitiesSection.tsx", + "ui/src/pages/apps/app-detail/SetupPanel.tsx", + "ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx" + ], + "fingerprint": "d1cedce55a218e81ba61a5f60d073b61cf6ce7235707a22de2305e10c3621544", + "stableId": "apps-notion-mcp-connect-flow-pap-16650", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)" + }, + { + "file": "ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx", + "capabilities": [ + "onboarding/adapter", + "onboarding/first-task" + ], + "purpose": "Onboarding/Agent arc", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/onboarding.md", + "ui/src/components/AgentRunnerSelect.tsx", + "ui/src/components/OnboardingWizard.tsx", + "ui/src/components/onboarding/PillGuy.tsx", + "ui/src/components/onboarding/Stepper.tsx", + "ui/storybook/fixtures/onboardingDraft.ts", + "ui/storybook/fixtures/onboardingEnvironment.ts", + "ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx" + ], + "fingerprint": "17189193e8ad01810a0606a38862732c14bf43d541449acd4b342434826db096", + "stableId": "onboarding-agent-arc", + "sidebarTitle": "Getting started and access/Onboarding and first work/Onboarding/Agent arc" + }, + { + "file": "ui/storybook/stories/onboarding/onboarding-character.stories.tsx", + "capabilities": [ + "onboarding/company" + ], + "purpose": "Onboarding/Character", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/onboarding.md", + "ui/src/components/onboarding/OnboardingCharacter.tsx", + "ui/src/components/ui/button.tsx", + "ui/storybook/stories/onboarding/onboarding-character.stories.tsx" + ], + "fingerprint": "4d3998161d4d1a02a72014f3ea33a41ff096969fec90f7abf50f2735b4453a8f", + "stableId": "onboarding-character", + "sidebarTitle": "Getting started and access/Onboarding and first work/Onboarding/Character" + }, + { + "file": "ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx", + "capabilities": [ + "onboarding/adapter", + "onboarding/first-task" + ], + "purpose": "Onboarding/Connect a model", + "status": "historical", + "implementation": "prototype", + "boundary": "fixtures", + "notes": "Older tile/mode preview; onboarding-agent-arc mounts the production wizard. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/onboarding.md", + "ui/src/components/onboarding/ConnectModelPreview.tsx", + "ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx" + ], + "replacement": "ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx", + "fingerprint": "96a36e9be6ba34c1e4c5eff1fb222474217b47559450916b79dba77462df9638", + "stableId": "onboarding-connect-a-model", + "sidebarTitle": "Getting started and access/Onboarding and first work/Onboarding/Connect a model" + }, + { + "file": "ui/storybook/stories/task-coordination/originating-tasks.stories.tsx", + "capabilities": [ + "task-coordination/delegation", + "projects/task-context" + ], + "purpose": "UX Labs/Tasks Created From a Task", + "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", + "sources": [ + "maps/feature-map/projects.md", + "maps/feature-map/task-coordination.md", + "ui/src/components/Layout.tsx", + "ui/src/components/task-detail/TaskDetailTasksPanel.tsx", + "ui/src/pages/IssueDetail.tsx", + "ui/src/pages/ProjectDetail.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx", + "ui/storybook/stories/task-coordination/originating-tasks.stories.tsx" + ], + "fingerprint": "e16a7c6351086b45005478d57101377fe51d5c2e8f8ae59ee0f3a32b1811d09d", + "stableId": "ux-labs-tasks-created-from-a-task", + "sidebarTitle": "Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task" + }, + { + "file": "ui/storybook/stories/overview/overview.stories.tsx", + "capabilities": [], + "purpose": "Overview/Storybook Guide", + "status": "unreviewed", + "implementation": "guide", + "boundary": "static", + "notes": "Generated inventory counts replace stale hand-maintained Planned/Covered claims; no execution coverage is implied.", + "reviewedAt": "2026-10-12", + "sources": [ + "ui/src/components/ui/badge.tsx", + "ui/src/components/ui/input.tsx", + "ui/storybook/stories/overview/overview.stories.tsx" + ], + "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", + "fingerprint": "cc12edb19c1e75db6869f343a6d07b6f1d11faff9ecfbf18a38a7d710a01e6d3", + "stableId": "overview-storybook-guide", + "sidebarTitle": "Overview/Storybook Guide" + }, + { + "file": "ui/storybook/stories/steering/paused-composer.stories.tsx", + "capabilities": [ + "steering/stop-and-pause" + ], + "purpose": "Composer/Paused task takeover", + "status": "historical", + "implementation": "prototype", + "boundary": "fixtures", + "notes": "Retained local paused takeover; task-execution-controls exercises the shipped pause/stop controls.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/steering.md", + "ui/src/components/task-chat/TaskChatComposer.tsx", + "ui/storybook/prototypes/PausedTaskComposer.tsx", + "ui/storybook/stories/steering/paused-composer.stories.tsx" + ], + "replacement": "ui/storybook/stories/steering/task-execution-controls.stories.tsx", + "fingerprint": "cd90d40ff87a2ce1da5f1a815607055fbed0f9ea42cb74ffe170c769316d570b", + "stableId": "composer-paused-task-takeover", + "sidebarTitle": "Tasks and conversations/Steering and queued messages/Composer/Paused task takeover" + }, + { + "file": "ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx", + "capabilities": [ + "app-permissions/agent-access", + "app-permissions/action-policy" + ], + "purpose": "Tools/Permitted vs Installed", + "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", + "sources": [ + "maps/feature-map/app-permissions.md", + "ui/src/components/IssueThreadInteractionCard.tsx", + "ui/src/pages/AgentToolsTab.tsx", + "ui/src/pages/apps/AppsConnect.tsx", + "ui/src/pages/apps/app-detail/PermissionsPanel.tsx", + "ui/src/pages/apps/app-detail/types.ts", + "ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx" + ], + "fingerprint": "8142253f986d8f969b73602f8832ded6b86f459f8ad46257af48655bc737b234", + "stableId": "tools-permitted-vs-installed", + "sidebarTitle": "Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed" + }, + { + "file": "ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx", + "capabilities": [ + "connection-setup/identity-and-access" + ], + "purpose": "Reviews/PAP-17835 Personal connection identity", + "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", + "sources": [ + "maps/feature-map/connection-setup.md", + "ui/src/pages/apps/app-detail/IdentitiesSection.tsx", + "ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx" + ], + "fingerprint": "2ac1d0214d0f286f3c35bd2ef7d50c59bc038ba314976e22f2900918e18e876a", + "stableId": "reviews-pap-17835-personal-connection-identity", + "sidebarTitle": "Apps, channels, and extensions/Connection setup/Reviews/PAP-17835 Personal connection identity" + }, + { + "file": "ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx", + "capabilities": [ + "agents/organization", + "tasks/create", + "agent-chat/discovery" + ], + "purpose": "Primary Agent/00 Review guide", + "status": "unreviewed", + "implementation": "guide", + "boundary": "static", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "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": "12e9fb092fb9b374a621f5408f5b965735d4882da8a25ba50f6984c74dad068e", + "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", + "tasks/create", + "agent-chat/discovery" + ], + "purpose": "Primary Agent/01 Components", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "maps/feature-map/agents.md", + "maps/feature-map/tasks.md", + "ui/src/components/AgentIdentity.tsx", + "ui/src/components/Layout.production.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/SidebarAgents.tsx", + "ui/src/components/primary-agent/PrimaryAgentPresentation.tsx", + "ui/src/pages/AgentChat.tsx", + "ui/src/pages/AgentChats.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.production.tsx", + "ui/src/pages/Agents.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/primary-agent/01-components.stories.tsx", + "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "c90b622fe0b8451444052b7da360ac3cf3fc265fc904f722a15cc84abf9a6af8", + "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", + "tasks/create", + "agent-chat/discovery" + ], + "purpose": "Primary Agent/02 Sidebar", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "maps/feature-map/agents.md", + "maps/feature-map/tasks.md", + "ui/src/components/Layout.production.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/SidebarAgents.tsx", + "ui/src/components/primary-agent/PrimaryAgentPresentation.tsx", + "ui/src/pages/AgentChat.tsx", + "ui/src/pages/AgentChats.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.production.tsx", + "ui/src/pages/Agents.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx", + "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "a66bd850ae9342250019d93f64675b40034521046bfefa7463485ba75b204053", + "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", + "tasks/create", + "agent-chat/discovery" + ], + "purpose": "Primary Agent/03 Agents", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "maps/feature-map/agents.md", + "maps/feature-map/tasks.md", + "ui/src/components/Layout.production.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/SidebarAgents.tsx", + "ui/src/components/primary-agent/PrimaryAgentPresentation.tsx", + "ui/src/pages/AgentChat.tsx", + "ui/src/pages/AgentChats.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.production.tsx", + "ui/src/pages/Agents.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx", + "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "5434893e2bb65a53d8b001a8300115ad37e61e06aab724ad7ba7ebc192246ab5", + "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", + "tasks/create", + "agent-chat/discovery" + ], + "purpose": "Primary Agent/04 New task", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "maps/feature-map/agents.md", + "maps/feature-map/tasks.md", + "ui/src/components/Layout.production.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/SidebarAgents.tsx", + "ui/src/components/primary-agent/PrimaryAgentPresentation.tsx", + "ui/src/pages/AgentChat.tsx", + "ui/src/pages/AgentChats.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.production.tsx", + "ui/src/pages/Agents.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx", + "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "b93a2799055f25436e136f591115f02dc41d041dfcd4a551f846ba1c7b1e1acd", + "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", + "tasks/create", + "agent-chat/discovery" + ], + "purpose": "Primary Agent/05 Chat", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "maps/feature-map/agents.md", + "maps/feature-map/tasks.md", + "ui/src/components/Layout.production.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/SidebarAgents.tsx", + "ui/src/components/primary-agent/PrimaryAgentPresentation.tsx", + "ui/src/pages/AgentChat.tsx", + "ui/src/pages/AgentChats.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.production.tsx", + "ui/src/pages/Agents.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx", + "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "e57db27b064518ba1f9f52937714ccd65d8a14a3cc6e81280a8b76acdd0c03c9", + "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", + "tasks/create", + "agent-chat/discovery" + ], + "purpose": "Primary Agent/06 Journeys", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "maps/feature-map/agents.md", + "maps/feature-map/tasks.md", + "ui/src/components/Layout.production.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/SidebarAgents.tsx", + "ui/src/components/primary-agent/PrimaryAgentPresentation.tsx", + "ui/src/pages/AgentChat.tsx", + "ui/src/pages/AgentChats.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.production.tsx", + "ui/src/pages/Agents.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx", + "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "5470489ab0b52e9ba8576bb3b8f8b6cf239835b1942cfd4d6b147eba00ea17e4", + "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", + "tasks/create", + "agent-chat/discovery" + ], + "purpose": "Primary Agent/07 Confirmation", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "maps/feature-map/agents.md", + "maps/feature-map/tasks.md", + "ui/src/components/Layout.production.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/SidebarAgents.tsx", + "ui/src/components/primary-agent/PrimaryAgentPresentation.tsx", + "ui/src/pages/AgentChat.tsx", + "ui/src/pages/AgentChats.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/Agents.production.tsx", + "ui/src/pages/Agents.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx", + "ui/storybook/support/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "cab2b681a80972e9655d69ce62f79e9d6f75f4555051bc970e41b57d0173fca4", + "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": [], + "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.", + "reviewedAt": "2026-10-12", + "sources": [ + "ui/src/components/ui/alert-dialog.tsx", + "ui/src/components/ui/avatar.tsx", + "ui/src/components/ui/breadcrumb.tsx", + "ui/src/components/ui/button.tsx", + "ui/src/components/ui/collapsible.tsx", + "ui/src/components/ui/command.tsx", + "ui/src/components/ui/dropdown-menu.tsx", + "ui/src/components/ui/radio-card.tsx", + "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" + ], + "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", + "fingerprint": "b8a0afd9a2fefb2e89e3215ca86a7dea3673f254d361f7d821d43c13c2a795bc", + "stableId": "foundations-primitive-coverage", + "sidebarTitle": "Foundations/Primitive Coverage" + }, + { + "file": "ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx", + "capabilities": [ + "access/resources", + "tasks/properties" + ], + "purpose": "Private tasks/00 Review guide", + "status": "unreviewed", + "implementation": "guide", + "boundary": "static", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/access.md", + "maps/feature-map/tasks.md", + "ui/storybook/stories/access/private-tasks/00-review-guide.stories.tsx" + ], + "fingerprint": "9c6ac52cc6d027359890f69a49ed413a092f36c7eb305b36d8bab54070633b9d", + "stableId": "private-tasks-00-review-guide", + "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/00 Review guide" + }, + { + "file": "ui/storybook/stories/access/private-tasks/01-creation.stories.tsx", + "capabilities": [ + "access/resources", + "tasks/properties" + ], + "purpose": "Private tasks/01 Creation", + "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", + "sources": [ + "maps/feature-map/access.md", + "maps/feature-map/tasks.md", + "ui/src/components/Layout.tsx", + "ui/src/components/ToastViewport.tsx", + "ui/src/pages/DesignGuide.tsx", + "ui/src/pages/IssueDetail.tsx", + "ui/src/pages/Issues.tsx", + "ui/src/pages/ProjectDetail.tsx", + "ui/src/pages/Projects.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/privateTasks.ts", + "ui/storybook/stories/access/private-tasks/01-creation.stories.tsx", + "ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx", + "ui/storybook/support/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "03b3b019f9495280cc89648b901f4f49396c97e6cb8f25354d170f47ec968ffb", + "stableId": "private-tasks-01-creation", + "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/01 Creation" + }, + { + "file": "ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx", + "capabilities": [ + "access/resources", + "tasks/properties" + ], + "purpose": "Private tasks/02 Sharing", + "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", + "sources": [ + "maps/feature-map/access.md", + "maps/feature-map/tasks.md", + "ui/src/components/IssueShareSheet.tsx", + "ui/src/components/ToastViewport.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/privateTasks.ts", + "ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx", + "ui/storybook/support/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "14eef8227c4193de41f7a75c7d0b8857e129a8d45c8eea619189f9a70f984b6f", + "stableId": "private-tasks-02-sharing", + "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/02 Sharing" + }, + { + "file": "ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx", + "capabilities": [ + "access/resources", + "tasks/properties" + ], + "purpose": "Private tasks/03 Task actions", + "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", + "sources": [ + "maps/feature-map/access.md", + "maps/feature-map/tasks.md", + "ui/src/components/IssuePrivacyActions.tsx", + "ui/src/components/ToastViewport.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/privateTasks.ts", + "ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx", + "ui/storybook/support/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "767e097ad435242b2bbf6453b58636458f2f0edcec0a9838443ada2bcced1ada", + "stableId": "private-tasks-03-task-actions", + "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/03 Task actions" + }, + { + "file": "ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx", + "capabilities": [ + "access/resources", + "tasks/properties" + ], + "purpose": "Private tasks/04 References and blockers", + "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", + "sources": [ + "maps/feature-map/access.md", + "maps/feature-map/tasks.md", + "ui/src/components/IssueBlockedNotice.tsx", + "ui/src/components/IssueReferencePill.tsx", + "ui/src/components/LockedIssueChip.tsx", + "ui/src/components/MarkdownBody.tsx", + "ui/src/components/ToastViewport.tsx", + "ui/src/components/issue-properties/relation-controls.tsx", + "ui/src/components/task-detail/TaskDetailRelationsPanel.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/privateTasks.ts", + "ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx", + "ui/storybook/support/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "a0dc537873e6362097e5cdd7a7cfe141f42303b3e975547d03f428a937a6c037", + "stableId": "private-tasks-04-references-and-blockers", + "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers" + }, + { + "file": "ui/storybook/stories/access/private-tasks/05-projects.stories.tsx", + "capabilities": [ + "access/resources", + "tasks/properties", + "projects/lifecycle" + ], + "purpose": "Private tasks/05 Projects", + "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", + "sources": [ + "maps/feature-map/access.md", + "maps/feature-map/projects.md", + "maps/feature-map/tasks.md", + "ui/src/components/IssueShareSheet.tsx", + "ui/src/components/ProjectAccessMembers.tsx", + "ui/src/components/ProjectProperties.tsx", + "ui/src/components/ToastViewport.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/privateTasks.ts", + "ui/storybook/stories/access/private-tasks/05-projects.stories.tsx", + "ui/storybook/support/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "c3ae29a4a0e550629a17eaf817dc3d385e9ed811d3bcd6bf06859e0822378681", + "stableId": "private-tasks-05-projects", + "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/05 Projects" + }, + { + "file": "ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx", + "capabilities": [ + "access/resources", + "tasks/properties", + "projects/lifecycle" + ], + "purpose": "Private tasks/06 Full product pages", + "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", + "sources": [ + "maps/feature-map/access.md", + "maps/feature-map/projects.md", + "maps/feature-map/tasks.md", + "ui/src/components/Layout.tsx", + "ui/src/components/ToastViewport.tsx", + "ui/src/pages/DesignGuide.tsx", + "ui/src/pages/IssueDetail.tsx", + "ui/src/pages/Issues.tsx", + "ui/src/pages/ProjectDetail.tsx", + "ui/src/pages/Projects.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/privateTasks.ts", + "ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx", + "ui/storybook/support/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "bbdceb12f6ba702663f243dd3986c5295677c7e9e007ffbed7cbe3efb385ab52", + "stableId": "private-tasks-06-full-product-pages", + "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages" + }, + { + "file": "ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx", + "capabilities": [ + "access/resources", + "tasks/properties" + ], + "purpose": "Private tasks/07 User stories", + "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", + "sources": [ + "maps/feature-map/access.md", + "maps/feature-map/tasks.md", + "ui/src/components/IssueBlockedNotice.tsx", + "ui/src/components/IssuePrivacyActions.tsx", + "ui/src/components/IssueReferencePill.tsx", + "ui/src/components/IssueShareSheet.tsx", + "ui/src/components/Layout.tsx", + "ui/src/components/LockedIssueChip.tsx", + "ui/src/components/MarkdownBody.tsx", + "ui/src/components/ProjectAccessMembers.tsx", + "ui/src/components/ProjectProperties.tsx", + "ui/src/components/ToastViewport.tsx", + "ui/src/components/issue-properties/relation-controls.tsx", + "ui/src/components/task-detail/TaskDetailRelationsPanel.tsx", + "ui/src/pages/DesignGuide.tsx", + "ui/src/pages/IssueDetail.tsx", + "ui/src/pages/Issues.tsx", + "ui/src/pages/ProjectDetail.tsx", + "ui/src/pages/Projects.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/privateTasks.ts", + "ui/storybook/stories/access/private-tasks/01-creation.stories.tsx", + "ui/storybook/stories/access/private-tasks/02-sharing.stories.tsx", + "ui/storybook/stories/access/private-tasks/03-task-actions.stories.tsx", + "ui/storybook/stories/access/private-tasks/04-references-and-blockers.stories.tsx", + "ui/storybook/stories/access/private-tasks/05-projects.stories.tsx", + "ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx", + "ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx", + "ui/storybook/support/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "361fb8ed1b95d3668529e2fa2c2d3951524909468134613f6e0c00d062a3ac82", + "stableId": "private-tasks-07-user-stories", + "sidebarTitle": "Getting started and access/Login, invitations, and access/Private tasks/07 User stories" + }, + { + "file": "ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx", + "capabilities": [ + "projects/defaults", + "workspaces/provisioning" + ], + "purpose": "Workspaces/Project runtime provision command", + "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", + "sources": [ + "maps/feature-map/projects.md", + "maps/feature-map/workspaces.md", + "ui/src/components/ProjectProperties.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx" + ], + "fingerprint": "d3c1ddd40e4acf1b30ee80655773c475e71885b75fe61b94c5a7ab1010fcb7ed", + "stableId": "workspaces-project-runtime-provision-command", + "sidebarTitle": "Projects and execution/Projects and repositories/Workspaces/Project runtime provision command" + }, + { + "file": "ui/storybook/stories/projects/project-repos-configuration.stories.tsx", + "capabilities": [ + "projects/repositories" + ], + "purpose": "Proposals/Project repos/Configuration", + "status": "proposed", + "implementation": "mixed", + "boundary": "fixtures", + "notes": "Production properties plus local configuration page composition; repository interaction is simulated.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/projects.md", + "ui/src/components/PageTabBar.tsx", + "ui/src/components/ProjectProperties.tsx", + "ui/src/components/RepositoryEditor.tsx", + "ui/src/components/SearchableSelect.tsx", + "ui/src/components/icons/github-icon.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/project-repos/ProjectConfigurationPage.tsx", + "ui/storybook/prototypes/project-repos/ProjectReposPrototype.tsx", + "ui/storybook/prototypes/project-repos/fixtures.ts", + "ui/storybook/stories/projects/project-repos-configuration.stories.tsx" + ], + "fingerprint": "8d96ac58686362abbaf468ba7cc400d43612fe0c8c21239cfa3fab2079ff5270", + "stableId": "proposals-project-repos-configuration", + "sidebarTitle": "Projects and execution/Projects and repositories/Proposals/Project repos/Configuration" + }, + { + "file": "ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx", + "capabilities": [ + "projects/lifecycle", + "goals/hierarchy", + "workspaces/binding" + ], + "purpose": "Product/Projects Goals Workspaces", + "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", + "sources": [ + "maps/feature-map/goals.md", + "maps/feature-map/projects.md", + "maps/feature-map/workspaces.md", + "ui/src/components/GoalProperties.tsx", + "ui/src/components/GoalTree.tsx", + "ui/src/components/ProjectProperties.tsx", + "ui/src/components/ProjectWorkspaceSummaryCard.tsx", + "ui/src/components/ProjectWorkspacesContent.tsx", + "ui/src/components/WorkspaceRuntimeControls.tsx", + "ui/src/components/WorktreeBanner.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx" + ], + "fingerprint": "4aa89b2f36fd6db30b20528b2e95063a8846279d28623f927de35acd75ce64f5", + "stableId": "product-projects-goals-workspaces", + "sidebarTitle": "Projects and execution/Projects and repositories/Product/Projects Goals Workspaces" + }, + { + "file": "ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Provider routing/00 Overview", + "status": "unreviewed", + "implementation": "guide", + "boundary": "static", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/storybook/prototypes/provider-routing/ReviewIndex.tsx", + "ui/storybook/prototypes/provider-routing/shared.tsx", + "ui/storybook/prototypes/provider-routing/story-support.ts", + "ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx" + ], + "fingerprint": "bc8772a5d090687032ad20a4c7971b529671492ebd92898562d85a01c0a14ff0", + "stableId": "ai-connections-provider-routing-00-overview", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/00 Overview" + }, + { + "file": "ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Provider routing/01 Onboarding", + "status": "mixed", + "implementation": "mixed", + "boundary": "fixtures", + "notes": "Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/AdapterLoginChrome.tsx", + "ui/src/components/AdapterMark.tsx", + "ui/src/components/AgentActionButtons.tsx", + "ui/src/components/AgentConfigForm.tsx", + "ui/src/components/AgentContextualSidebar.tsx", + "ui/src/components/AgentRunnerSelect.tsx", + "ui/src/components/NewIssueDialog.tsx", + "ui/src/components/RuntimeTestCard.tsx", + "ui/src/components/agent-config-primitives.tsx", + "ui/src/components/ai-connections/AiConnectionManagement.tsx", + "ui/src/components/onboarding/CredentialModeLink.tsx", + "ui/src/components/onboarding/ModelSourceTiles.tsx", + "ui/src/components/onboarding/PillGuy.tsx", + "ui/src/features/connections/ConnectionChoiceList.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/AgentToolsTab.tsx", + "ui/src/pages/agent-detail-navigation.ts", + "ui/src/pages/agent-skills/AgentSkillsTab.tsx", + "ui/src/pages/apps/AppLogo.tsx", + "ui/storybook/fixtures/onboardingEnvironment.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/RuntimeTestCard.tsx", + "ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx", + "ui/storybook/prototypes/agent-settings/fixtures.ts", + "ui/storybook/prototypes/new-agent-fixtures.ts", + "ui/storybook/prototypes/provider-routing/AgentSetup.tsx", + "ui/storybook/prototypes/provider-routing/ConnectionPicker.tsx", + "ui/storybook/prototypes/provider-routing/ConnectionSetup.tsx", + "ui/storybook/prototypes/provider-routing/model.ts", + "ui/storybook/prototypes/provider-routing/shared.tsx", + "ui/storybook/prototypes/provider-routing/story-support.ts", + "ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx" + ], + "fingerprint": "5b02a524225cffca73d39ca8045afeceed255e1629f3964fa5568f5540d46328", + "stableId": "ai-connections-provider-routing-01-onboarding", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/01 Onboarding" + }, + { + "file": "ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Provider routing/02 Connect", + "status": "mixed", + "implementation": "mixed", + "boundary": "fixtures", + "notes": "Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/AdapterLoginChrome.tsx", + "ui/src/components/ai-connections/AiProviderSetup.tsx", + "ui/src/features/connections/ConnectionChoiceList.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/src/pages/apps/AppLogo.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/src/pages/apps/app-definition-display.ts", + "ui/storybook/prototypes/provider-routing/ConnectionCatalog.tsx", + "ui/storybook/prototypes/provider-routing/ConnectionSetup.tsx", + "ui/storybook/prototypes/provider-routing/model.ts", + "ui/storybook/prototypes/provider-routing/shared.tsx", + "ui/storybook/prototypes/provider-routing/story-support.ts", + "ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx" + ], + "fingerprint": "683c3cab73497cff4a8acb69d937b333266642fb016272f36f589b63fd43296d", + "stableId": "ai-connections-provider-routing-02-connect", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/02 Connect" + }, + { + "file": "ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Provider routing/03 Agent", + "status": "mixed", + "implementation": "mixed", + "boundary": "fixtures", + "notes": "Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/AdapterLoginChrome.tsx", + "ui/src/components/AdapterMark.tsx", + "ui/src/components/AgentActionButtons.tsx", + "ui/src/components/AgentConfigForm.tsx", + "ui/src/components/AgentContextualSidebar.tsx", + "ui/src/components/AgentRunnerSelect.tsx", + "ui/src/components/NewIssueDialog.tsx", + "ui/src/components/RuntimeTestCard.tsx", + "ui/src/components/agent-config-primitives.tsx", + "ui/src/components/ai-connections/AiConnectionManagement.tsx", + "ui/src/components/onboarding/CredentialModeLink.tsx", + "ui/src/components/onboarding/ModelSourceTiles.tsx", + "ui/src/components/onboarding/PillGuy.tsx", + "ui/src/features/connections/ConnectionChoiceList.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/src/pages/AgentDetail.tsx", + "ui/src/pages/AgentToolsTab.tsx", + "ui/src/pages/agent-detail-navigation.ts", + "ui/src/pages/agent-skills/AgentSkillsTab.tsx", + "ui/src/pages/apps/AppLogo.tsx", + "ui/storybook/fixtures/onboardingEnvironment.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/RuntimeTestCard.tsx", + "ui/storybook/prototypes/agent-settings/AgentSettingsPreview.tsx", + "ui/storybook/prototypes/agent-settings/fixtures.ts", + "ui/storybook/prototypes/new-agent-fixtures.ts", + "ui/storybook/prototypes/provider-routing/AgentSetup.tsx", + "ui/storybook/prototypes/provider-routing/ConnectionPicker.tsx", + "ui/storybook/prototypes/provider-routing/ConnectionSetup.tsx", + "ui/storybook/prototypes/provider-routing/model.ts", + "ui/storybook/prototypes/provider-routing/shared.tsx", + "ui/storybook/prototypes/provider-routing/story-support.ts", + "ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx" + ], + "fingerprint": "72aa608dbb3c4e359241385a2d7b5ffd25878726029b1d5c6935a4155051b295", + "stableId": "ai-connections-provider-routing-03-agent", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/03 Agent" + }, + { + "file": "ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Provider routing/04 Manage", + "status": "mixed", + "implementation": "mixed", + "boundary": "fixtures", + "notes": "Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/AdapterLoginChrome.tsx", + "ui/src/components/ai-connections/AiProviderSetup.tsx", + "ui/src/features/connections/ConnectionChoiceList.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/src/pages/apps/AppLogo.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/src/pages/apps/app-definition-display.ts", + "ui/storybook/prototypes/provider-routing/ConnectionCatalog.tsx", + "ui/storybook/prototypes/provider-routing/ConnectionSetup.tsx", + "ui/storybook/prototypes/provider-routing/Management.tsx", + "ui/storybook/prototypes/provider-routing/RoutingAccess.tsx", + "ui/storybook/prototypes/provider-routing/model.ts", + "ui/storybook/prototypes/provider-routing/shared.tsx", + "ui/storybook/prototypes/provider-routing/story-support.ts", + "ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx" + ], + "fingerprint": "ee361ab94561034dfbc783f5a1898a9f5b84c7bdef1ae9a0d2b17da22e7a2387", + "stableId": "ai-connections-provider-routing-04-manage", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/04 Manage" + }, + { + "file": "ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Provider routing/05 Recovery", + "status": "mixed", + "implementation": "mixed", + "boundary": "fixtures", + "notes": "Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/AdapterLoginChrome.tsx", + "ui/src/features/connections/ConnectionIntentInteractionBody.tsx", + "ui/src/features/connections/ConnectionSetupFlow.tsx", + "ui/storybook/prototypes/provider-routing/Recovery.tsx", + "ui/storybook/prototypes/provider-routing/shared.tsx", + "ui/storybook/prototypes/provider-routing/story-support.ts", + "ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx" + ], + "fingerprint": "d03f0974269ac139130baf91b31b439947affe293a2639368bf8bb05868e5352", + "stableId": "ai-connections-provider-routing-05-recovery", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/05 Recovery" + }, + { + "file": "ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx", + "capabilities": [ + "runs-adapters/validation" + ], + "purpose": "AI Connections/Provider routing/06 Production components", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Design-review route uses simulated account/provider state; audit the rendered subject before treating it as the live connection flow. Runner display/default/effort and matching-discovery source changes reviewed; existing fixture, proposal and historical scope retained. No new rendering, play, backend or provider qualification is claimed.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/AgentConfigForm.tsx", + "ui/src/components/AgentRunnerSelect.tsx", + "ui/src/components/agent-config-primitives.tsx", + "ui/src/components/ai-connections/AiConnectionField.tsx", + "ui/src/components/ai-connections/AiConnectionSelect.tsx", + "ui/src/components/ai-connections/AiProviderSetup.tsx", + "ui/src/components/ai-connections/useConnectionModels.ts", + "ui/src/components/new-agent/AgentProviderConnection.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/src/pages/apps/app-definition-display.ts", + "ui/storybook/prototypes/provider-routing/ConnectionCatalog.tsx", + "ui/storybook/prototypes/provider-routing/model.ts", + "ui/storybook/prototypes/provider-routing/shared.tsx", + "ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx" + ], + "fingerprint": "fba4e12f554fb8fdcbdf72276e71d0d891716055b507951352216ebd93f5932f", + "stableId": "ai-connections-provider-routing-06-production-components", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/06 Production components" + }, + { + "file": "ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx", + "capabilities": [ + "gateways-profiles/client-connect" + ], + "purpose": "Assistant connections/Manage connections", + "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", + "sources": [ + "maps/feature-map/gateways-profiles.md", + "ui/src/pages/McpConnect.tsx", + "ui/storybook/fixtures/publicMcp.ts", + "ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx" + ], + "fingerprint": "59a7260fb8b5b123828d9d0d0d2cd081d1456bbdf0ebedf7e65e5b880fe084b5", + "stableId": "assistant-connections-manage-connections", + "sidebarTitle": "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Manage connections" + }, + { + "file": "ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx", + "capabilities": [ + "gateways-profiles/client-connect" + ], + "purpose": "Assistant connections/Consent", + "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", + "sources": [ + "maps/feature-map/gateways-profiles.md", + "ui/src/pages/McpConnect.tsx", + "ui/storybook/fixtures/publicMcp.ts", + "ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx" + ], + "fingerprint": "263a310d8c4fdf4c693f77dddc923880330d9be73c69cbb05b83d3ed4d59490a", + "stableId": "assistant-connections-consent", + "sidebarTitle": "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Consent" + }, + { + "file": "ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx", + "capabilities": [ + "gateways-profiles/client-connect" + ], + "purpose": "Assistant connections/Device authorization", + "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", + "sources": [ + "maps/feature-map/gateways-profiles.md", + "ui/src/pages/McpConnect.tsx", + "ui/storybook/fixtures/publicMcp.ts", + "ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx" + ], + "fingerprint": "931a100cc1ad520bdb43712fad8b0284e47ad1a3ee531d55a5cbff0a430907c0", + "stableId": "assistant-connections-device-authorization", + "sidebarTitle": "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Device authorization" + }, + { + "file": "ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx", + "capabilities": [ + "gateways-profiles/client-connect" + ], + "purpose": "Assistant connections/Start here", + "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", + "sources": [ + "maps/feature-map/gateways-profiles.md", + "ui/src/components/Layout.tsx", + "ui/src/pages/InstanceExperimentalSettings.tsx", + "ui/src/pages/apps/AssistantConnection.tsx", + "ui/src/pages/apps/Browse.tsx", + "ui/storybook/fixtures/publicMcp.ts", + "ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx", + "ui/storybook/support/public-mcp-presenter.tsx" + ], + "fingerprint": "683c517cdaa396555d0bafe049401ccd9768c4d4dec58c3fc4cbbb292e7c147d", + "stableId": "assistant-connections-start-here", + "sidebarTitle": "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Start here" + }, + { + "file": "ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx", + "capabilities": [ + "gateways-profiles/client-connect" + ], + "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", + "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" + ], + "fingerprint": "15f99c5632c11f8e90dfaa340d192b90b07138176317e17e51a176952383adb7", + "stableId": "assistant-connections-experimental-setting", + "sidebarTitle": "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting" + }, + { + "file": "ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx", + "capabilities": [ + "recovery/diagnosis", + "recovery/source-action" + ], + "purpose": "Design previews/Recovery notice", + "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", + "sources": [ + "maps/feature-map/recovery.md", + "ui/src/components/DispositionRecoveryNotice.tsx", + "ui/src/components/task-chat/TaskChatSystemNotice.tsx", + "ui/src/components/task-chat/task-chat-model.ts", + "ui/storybook/prototypes/recovery-notice/RecoveryNoticePreview.tsx", + "ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx" + ], + "fingerprint": "4e880f02947c96e751ecca7a0f662106874ac5b58f254915592ecdec699298de", + "stableId": "design-previews-recovery-notice", + "sidebarTitle": "Projects and execution/Recovery/Design previews/Recovery notice" + }, + { + "file": "ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx", + "capabilities": [ + "skills/versions", + "skills/discovery" + ], + "purpose": "Skills/Repository packages", + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/components/Layout.tsx", + "ui/src/pages/CompanySkills.tsx", + "ui/src/pages/SkillSources.tsx", + "ui/src/pages/SkillStudio.tsx", + "ui/storybook/fixtures/githubSkillSources.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/skills/github-skills-journey.stories.tsx", + "ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx" + ], + "fingerprint": "a90fb6c9377034d3645b851711ddc84eb3b9945dce0cdea4496cb9a4050fb8ad", + "stableId": "skills-repository-packages", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages" + }, + { + "file": "ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx", + "capabilities": [ + "questions-and-approvals/questions", + "questions-and-approvals/confirmations", + "questions-and-approvals/audience" + ], + "purpose": "Chat & Comments/Rich ACP 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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/questions-and-approvals.md", + "ui/src/components/IssueThreadInteractionCard.tsx", + "ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx", + "ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx", + "ui/src/components/task-chat/TaskChatProtocolCard.tsx", + "ui/src/components/task-chat/task-chat-model.ts", + "ui/src/components/task-chat/transcript-adapter.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx" + ], + "fingerprint": "6a524bb57fd5e0c0e70ce6cb4b7d61076293f4380f93707a6786f1cccbdcc887", + "stableId": "chat-comments-rich-acp-interactions", + "sidebarTitle": "Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions" + }, + { + "file": "ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx", + "capabilities": [ + "documents-artifacts/work-products" + ], + "purpose": "Task Chat/Rich Work Product Cards", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/task-chat/RichWorkProductCard.tsx", + "ui/src/components/task-chat/TaskChatBubble.tsx", + "ui/src/components/task-chat/task-chat-model.ts", + "ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx" + ], + "fingerprint": "e02eeeed61f5311ac298a8655906089e96ca867670a1deebcc36fb7432fb3153", + "stableId": "task-chat-rich-work-product-cards", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Task Chat/Rich Work Product Cards" + }, + { + "file": "ui/storybook/stories/routines/routine-detail-c.stories.tsx", + "capabilities": [ + "routines/definition", + "routines/management" + ], + "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", + "sources": [ + "maps/feature-map/routines.md", + "ui/src/components/AgentActionButtons.tsx", + "ui/src/components/RoutineSaveBar.tsx", + "ui/src/components/RoutineSubSidebar.tsx", + "ui/src/components/routine-sections/context.tsx", + "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" + ], + "fingerprint": "c07544c830cd35d90c553d9388e2a9479e42f69460a6c506e1c541cea78607d4", + "stableId": "product-routines-·-detail-variation-c", + "sidebarTitle": "Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)" + }, + { + "file": "ui/storybook/stories/routines/routine-secrets.stories.tsx", + "capabilities": [ + "routines/definition", + "secrets/runtime-delivery" + ], + "purpose": "Product/Routines · Secrets tab", + "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", + "sources": [ + "maps/feature-map/routines.md", + "maps/feature-map/secrets.md", + "ui/src/components/RoutineHistoryTab.tsx", + "ui/src/components/environment-variables-editor/index.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/routines/routine-secrets.stories.tsx" + ], + "fingerprint": "03c05c0cca01d220885ea96f13495bcc185121eecbbeb00918511b4d7de26016", + "stableId": "product-routines-·-secrets-tab", + "sidebarTitle": "Automation and structured work/Routines, schedules, and triggers/Product/Routines · Secrets tab" + }, + { + "file": "ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx", + "capabilities": [ + "routines/triggers" + ], + "purpose": "Product/Routines/Webhooks UX preview", + "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", + "sources": [ + "maps/feature-map/routines.md", + "ui/src/components/Layout.tsx", + "ui/src/components/routine-triggers/TriggerCard.tsx", + "ui/src/components/routine-triggers/TriggerWizard.tsx", + "ui/src/components/routine-triggers/WebhookFields.tsx", + "ui/src/pages/RoutineDetail.tsx", + "ui/src/pages/Routines.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/routineTriggerCard.tsx", + "ui/storybook/fixtures/routineTriggerWizard.tsx", + "ui/storybook/fixtures/routineWebhookFields.tsx", + "ui/storybook/fixtures/routineWebhooks.tsx", + "ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx" + ], + "fingerprint": "96129f07a13497700039df8275b2f8d3d74468793e042c01e61bda2bdc1ad8fe", + "stableId": "product-routines-webhooks-ux-preview", + "sidebarTitle": "Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks UX preview" + }, + { + "file": "ui/storybook/stories/routines/routine-webhooks.stories.tsx", + "capabilities": [ + "routines/triggers" + ], + "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", + "sources": [ + "maps/feature-map/routines.md", + "ui/src/components/Layout.tsx", + "ui/src/pages/RoutineDetail.tsx", + "ui/src/pages/Routines.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/routineWebhooks.tsx", + "ui/storybook/stories/routines/routine-webhooks.stories.tsx" + ], + "fingerprint": "588536cb71d6c79eff20ea1dc14d60c96932631f267a45adc36e2078d816aa39", + "stableId": "product-routines-webhooks", + "sidebarTitle": "Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks" + }, + { + "file": "ui/storybook/stories/routines/routines-list-groups.stories.tsx", + "capabilities": [ + "routines/definition", + "routines/management" + ], + "purpose": "Product/Routines · List (grouped cards)", + "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", + "sources": [ + "maps/feature-map/routines.md", + "ui/src/components/RoutineList.tsx", + "ui/storybook/stories/routines/routines-list-groups.stories.tsx" + ], + "fingerprint": "02a433aeee5be2ecc3284f0a27c45c4222e3abf05cbd478e55bfb7462a301d19", + "stableId": "product-routines-·-list-grouped-cards", + "sidebarTitle": "Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)" + }, + { + "file": "ui/storybook/stories/tasks/runner-activity.stories.tsx", + "capabilities": [ + "tasks/discussion", + "runs-adapters/runs" + ], + "purpose": "Tasks/Runner activity preview", + "status": "current", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "maps/feature-map/tasks.md", + "ui/src/components/task-chat/TaskChatLiveRunPill.tsx", + "ui/src/components/task-chat/TaskChatLiveTail.tsx", + "ui/src/components/task-chat/TaskChatRunnerTurn.tsx", + "ui/src/components/task-chat/TaskChatThreadView.tsx", + "ui/src/components/task-chat/expansion-state.tsx", + "ui/src/components/task-chat/task-chat-model.ts", + "ui/src/components/task-chat/transcript-adapter.ts", + "ui/storybook/prototypes/runner-activity/RunnerActivityPreview.tsx", + "ui/storybook/stories/tasks/runner-activity.stories.tsx" + ], + "fingerprint": "191bee2b49405bbe22c32f773d4f66709cfe3410abbb0c91dd69e06b566d47d0", + "stableId": "tasks-runner-activity-preview", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview" + }, + { + "file": "ui/storybook/stories/recovery/scheduled-retry.stories.tsx", + "capabilities": [ + "recovery/bounded-continuation" + ], + "purpose": "Product/Issue Scheduled retry surfaces", + "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", + "sources": [ + "maps/feature-map/recovery.md", + "ui/src/components/IssueProperties.tsx", + "ui/src/components/IssueScheduledRetryCard.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/recovery/scheduled-retry.stories.tsx" + ], + "fingerprint": "b07bdc368ce48d184b5cd99156ed23c03251ac9ecd90de47812db351261cc5c0", + "stableId": "product-issue-scheduled-retry-surfaces", + "sidebarTitle": "Projects and execution/Recovery/Product/Issue Scheduled retry surfaces" + }, + { + "file": "ui/storybook/stories/inbox-search/search.stories.tsx", + "capabilities": [ + "inbox-search/search" + ], + "purpose": "Product/Search & Command K", + "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", + "sources": [ + "maps/feature-map/inbox-search.md", + "ui/src/components/PageTabBar.tsx", + "ui/src/components/StatusBadge.tsx", + "ui/src/components/search/MatchSourceChip.tsx", + "ui/src/components/search/SearchFilterBar.tsx", + "ui/src/components/search/SearchFilterChips.tsx", + "ui/src/components/search/SearchResultRow.tsx", + "ui/src/components/search/ZeroResultsRecovery.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/inbox-search/search.stories.tsx" + ], + "fingerprint": "dfd04cb52d810ed016fae1508dea6fa7baeb8a71676e605a4eff269d844957b6", + "stableId": "product-search-command-k", + "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K" + }, + { + "file": "ui/storybook/stories/workspaces/searchable-select.stories.tsx", + "capabilities": [ + "workspaces/binding" + ], + "purpose": "Components/SearchableSelect/Workspace picker", + "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", + "sources": [ + "maps/feature-map/workspaces.md", + "ui/src/components/SearchableSelect.tsx", + "ui/storybook/stories/workspaces/searchable-select.stories.tsx" + ], + "fingerprint": "a09a2ecf9c05843dc29ea4fd173fa7e9b0968defc8a83d48b2ffc81503b2f98f", + "stableId": "components-searchableselect-workspace-picker", + "sidebarTitle": "Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker" + }, + { + "file": "ui/storybook/stories/secrets/secrets.stories.tsx", + "capabilities": [ + "secrets/company-secrets", + "secrets/vault-import" + ], + "purpose": "Product/Secrets", + "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", + "sources": [ + "maps/feature-map/secrets.md", + "ui/src/components/SecretBindingPicker.tsx", + "ui/src/components/environment-variables-editor/index.tsx", + "ui/src/pages/Secrets.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/secrets/secrets.stories.tsx" + ], + "fingerprint": "246539c619974c29848ae6ba3685aa741dbc572eeb6f8cffb2e9328501fbf31b", + "stableId": "product-secrets", + "sidebarTitle": "Apps, channels, and extensions/Secrets and proposals/Product/Secrets" + }, + { + "file": "ui/storybook/stories/runs-adapters/session-goals.stories.tsx", + "capabilities": [ + "runs-adapters/session" + ], + "purpose": "Composer/Session goals", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/task-chat/RunnerGoalWidget.tsx", + "ui/storybook/stories/runs-adapters/session-goals.stories.tsx" + ], + "fingerprint": "780707e94f1428ee3e8475363981c35ef38e68fbb7fa34b14cf30ec63a74f2b6", + "stableId": "composer-session-goals", + "sidebarTitle": "Agents and reusable capabilities/Runs, harnesses, and model accounts/Composer/Session goals" + }, + { + "file": "ui/storybook/stories/skills/skill-import-progress.stories.tsx", + "capabilities": [ + "skills/versions", + "skills/discovery" + ], + "purpose": "Skills/Import progress", + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/pages/skills/SkillImportProgress.tsx", + "ui/storybook/fixtures/githubSkillSources.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/skills/skill-import-progress.stories.tsx" + ], + "fingerprint": "55477a243eb0cd940e20e47b0c2e388af26268930be5ee516043a8812f3ab0e5", + "stableId": "skills-import-progress", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress" + }, + { + "file": "ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx", + "capabilities": [ + "skills/policy" + ], + "purpose": "Product/Skills/Policy surfaces", + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/components/skill-studio/SkillPolicySurfaces.tsx", + "ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx" + ], + "fingerprint": "1f39ed395b4e147167ad855f867d7cb61d4829f4077e74ffe7c53029d67b67de", + "stableId": "product-skills-policy-surfaces", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Product/Skills/Policy surfaces" + }, + { + "file": "ui/storybook/stories/skills/skills-store-detail.stories.tsx", + "capabilities": [ + "skills/discovery" + ], + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/pages/CompanySkills.tsx", + "ui/storybook/stories/skills/skills-store-detail.stories.tsx" + ], + "fingerprint": "18c72ba27b8f06c186e21552c62d03a477ceb0b4f0d66253ed556830d7cc9a75", + "stableId": "skills-store-skill-detail", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail" + }, + { + "file": "ui/storybook/stories/skills/skills-store-discovery.stories.tsx", + "capabilities": [ + "skills/discovery" + ], + "purpose": "Skills Store/Discovery grid", + "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", + "sources": [ + "maps/feature-map/skills.md", + "ui/src/pages/CompanySkills.tsx", + "ui/storybook/stories/skills/skills-store-discovery.stories.tsx" + ], + "fingerprint": "8f492ae3f71ac39d7a752929df767b9d37a0e782e2d3ee515cfa1b5c22d0c1a9", + "stableId": "skills-store-discovery-grid", + "sidebarTitle": "Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Discovery grid" + }, + { + "file": "ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx", + "capabilities": [ + "chat-channels/providers", + "connection-setup/channel-setup" + ], + "purpose": "Connections/Slack/Automatic setup", + "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", + "sources": [ + "maps/feature-map/chat-channels.md", + "maps/feature-map/connection-setup.md", + "ui/src/components/chat/ChatSetupNavigation.tsx", + "ui/src/pages/apps/chat/ChatEndpointSetup.tsx", + "ui/storybook/fixtures/SlackSetupFixture.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx" + ], + "fingerprint": "42fb2d53434f7ba8cc5cce556818d6469e02da559bd40fb6dbd4fd5bec9aecb5", + "stableId": "connections-slack-automatic-setup", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup" + }, + { + "file": "ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx", + "capabilities": [ + "chat-channels/identity" + ], + "purpose": "Connections/Slack/Avatar in Settings", + "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", + "sources": [ + "maps/feature-map/chat-channels.md", + "ui/src/pages/apps/chat/ChatCommunicationInstructions.tsx", + "ui/src/pages/apps/chat/SlackAvatarStep.tsx", + "ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx", + "ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx" + ], + "fingerprint": "ed2053a543507e9ad110084b6974204897193fcc1d7a1c4cb23f15e1cfe5dcba", + "stableId": "connections-slack-avatar-in-settings", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings" + }, + { + "file": "ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx", + "capabilities": [ + "chat-channels/identity" + ], + "purpose": "Connections/Slack/Communication in Settings", + "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", + "sources": [ + "maps/feature-map/chat-channels.md", + "ui/src/pages/apps/chat/ChatCommunicationInstructions.tsx", + "ui/src/pages/apps/chat/SlackAvatarStep.tsx", + "ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx", + "ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx" + ], + "fingerprint": "c581843a009938f16173f6874a1edbe6a562c3c4382b3a674fcd3487d37a50ce", + "stableId": "connections-slack-communication-in-settings", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings" + }, + { + "file": "ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx", + "capabilities": [ + "chat-channels/providers", + "connection-setup/channel-setup" + ], + "purpose": "Connections/Slack/Managed setup", + "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", + "sources": [ + "maps/feature-map/chat-channels.md", + "maps/feature-map/connection-setup.md", + "ui/src/components/chat/ChatSetupNavigation.tsx", + "ui/src/pages/apps/chat/ChatEndpointSetup.tsx", + "ui/storybook/fixtures/SlackSetupFixture.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx" + ], + "fingerprint": "d947a4bd8dec88fd77c0b68e51ff12bab050342661e455a4f72443d82916d7c9", + "stableId": "connections-slack-managed-setup", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup" + }, + { + "file": "ui/storybook/stories/chat-channels/slack-tools.stories.tsx", + "capabilities": [ + "chat-channels/conversation" + ], + "purpose": "Connections/Slack/Task tools", + "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", + "sources": [ + "maps/feature-map/chat-channels.md", + "ui/src/pages/apps/chat/SlackToolSettings.tsx", + "ui/storybook/stories/chat-channels/slack-tools.stories.tsx" + ], + "fingerprint": "a8464a4cbed26f72dacc14e77326caa7d649b23eec075729667d48f267bb8a03", + "stableId": "connections-slack-task-tools", + "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools" + }, + { + "file": "ui/storybook/stories/recovery/source-issue-recovery.stories.tsx", + "capabilities": [ + "recovery/diagnosis", + "recovery/source-action" + ], + "purpose": "Paperclip/Source Issue Recovery", + "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", + "sources": [ + "maps/feature-map/recovery.md", + "ui/src/components/IssueBlockedNotice.tsx", + "ui/src/components/IssueRecoveryActionCard.tsx", + "ui/src/components/IssueRow.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/recovery/source-issue-recovery.stories.tsx" + ], + "fingerprint": "ad00308c561a44cfb60a7b359ac88b48ad5ee2f13d4b36af99552c29c0a3df67", + "stableId": "paperclip-source-issue-recovery", + "sidebarTitle": "Projects and execution/Recovery/Paperclip/Source Issue Recovery" + }, + { + "file": "ui/storybook/stories/recovery/source-resolved-fold.stories.tsx", + "capabilities": [ + "recovery/diagnosis", + "recovery/source-action" + ], + "purpose": "Paperclip/Source-resolved Fold", + "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", + "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": "f6a8c8477c45cbf1c98a0e0a8618373800183aa49a189b00efac62c42fb0b92e", + "stableId": "paperclip-source-resolved-fold", + "sidebarTitle": "Projects and execution/Recovery/Paperclip/Source-resolved Fold" + }, + { + "file": "ui/storybook/stories/foundations/status-language.stories.tsx", + "capabilities": [], + "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", + "sources": [ + "ui/src/components/CopyText.tsx", + "ui/src/components/EmptyState.tsx", + "ui/src/components/Identity.tsx", + "ui/src/components/IssueBlockedNotice.tsx", + "ui/src/components/IssueRow.tsx", + "ui/src/components/MetricCard.tsx", + "ui/src/components/PriorityIcon.tsx", + "ui/src/components/QuotaBar.tsx", + "ui/src/components/StatusBadge.tsx", + "ui/src/components/StatusIcon.tsx", + "ui/src/components/ui/card.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/foundations/status-language.stories.tsx" + ], + "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", + "fingerprint": "1c7360f6c527b4377c85ee10b1eeb4d268b1af77b7a403ed321129b918409b76", + "stableId": "foundations-status-language", + "sidebarTitle": "Foundations/Status Language" + }, + { + "file": "ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx", + "capabilities": [ + "task-coordination/dependencies", + "task-coordination/delegation" + ], + "purpose": "UX Labs/Sub-issues Workflow Checklist", + "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", + "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", + "stableId": "ux-labs-sub-issues-workflow-checklist", + "sidebarTitle": "Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist" + }, + { + "file": "ui/storybook/stories/tasks/successful-run-handoff.stories.tsx", + "capabilities": [ + "tasks/completion", + "recovery/diagnosis" + ], + "purpose": "Paperclip/Successful Run Handoff", + "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", + "sources": [ + "maps/feature-map/recovery.md", + "maps/feature-map/tasks.md", + "ui/src/components/Identity.tsx", + "ui/src/components/IssueBlockedNotice.tsx", + "ui/src/components/IssueChatThread.tsx", + "ui/src/components/KanbanBoard.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/tasks/successful-run-handoff.stories.tsx" + ], + "fingerprint": "dee060e56c932b5c076cde939cc8cad36b46452fd3fd85118e1eb6664deb47ad", + "stableId": "paperclip-successful-run-handoff", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff" + }, + { + "file": "ui/storybook/stories/agents/summary-slot-card.stories.tsx", + "capabilities": [ + "agents/built-in" + ], + "purpose": "Summaries/SummarySlotCard", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/components/SummarySlotCard.tsx", + "ui/storybook/stories/agents/summary-slot-card.stories.tsx" + ], + "fingerprint": "71610d108188e790a73c24d754dd1025db879c619a863dac97b7d8bac4b0fb62", + "stableId": "summaries-summaryslotcard", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard" + }, + { + "file": "ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx", + "capabilities": [ + "documents-artifacts/library", + "documents-artifacts/work-products" + ], + "purpose": "Tasks/Artifact Gallery", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/ImageGalleryModal.tsx", + "ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx", + "ui/src/components/task-chat/RichWorkProductCard.tsx", + "ui/src/components/task-chat/TaskChatBubble.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx" + ], + "fingerprint": "4d8181361cd7fd9c38ad04cde9ce1ed245498d063650890ca356a6c1e304c448", + "stableId": "tasks-artifact-gallery", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery" + }, + { + "file": "ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx", + "capabilities": [ + "tasks/discussion", + "runs-adapters/runs" + ], + "purpose": "Tasks/Task chat review fixes", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Runner-default recipe text reviewed; rendered subjects and fixture contracts are unchanged by that documentation update.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "maps/feature-map/tasks.md", + "ui/src/components/task-chat/TaskChatBubble.tsx", + "ui/src/components/task-chat/TaskChatRunnerTurn.tsx", + "ui/src/components/task-chat/TaskChatStatusPill.tsx", + "ui/src/components/task-chat/task-chat-adapter.ts", + "ui/src/components/task-chat/task-chat-model.ts", + "ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx" + ], + "fingerprint": "54ffccb3adcbf9197aca2903fa1bb1550f42f50740f005a3d2d6ca2330190074", + "stableId": "tasks-task-chat-review-fixes", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes" + }, + { + "file": "ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx", + "capabilities": [ + "questions-and-approvals/questions", + "questions-and-approvals/confirmations", + "questions-and-approvals/audience" + ], + "purpose": "Composer/Interaction above composer", + "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", + "sources": [ + "maps/feature-map/questions-and-approvals.md", + "ui/src/components/MobileBottomNav.tsx", + "ui/src/components/task-chat/TaskChatComposer.tsx", + "ui/src/components/task-chat/TaskChatComposerDock.tsx", + "ui/src/components/task-chat/TaskChatInteractionCard.tsx", + "ui/src/components/task-chat/TaskChatProtocolCard.tsx", + "ui/src/components/task-chat/task-chat-model.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/prototypes/composer-model-picker/fixtures.ts", + "ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx" + ], + "fingerprint": "65414677c895af91f8381eb20082f1d9cacd84631324060e170589c7e48a6488", + "stableId": "composer-interaction-above-composer", + "sidebarTitle": "Tasks and conversations/Questions and approvals/Composer/Interaction above composer" + }, + { + "file": "ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx", + "capabilities": [ + "tasks/discussion" + ], + "purpose": "Task chat/Mobile streaming follow", + "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", + "sources": [ + "maps/feature-map/tasks.md", + "ui/src/components/MobileBottomNav.tsx", + "ui/src/components/task-chat/TaskChatComposer.tsx", + "ui/src/components/task-chat/TaskChatComposerDock.tsx", + "ui/src/components/task-chat/TaskChatThreadView.tsx", + "ui/src/components/task-chat/useWindowAutoFollow.ts", + "ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx" + ], + "fingerprint": "5050a4cd07125dc0a5375950e14fb8d73c87c5832271973cf593b2d619e156d4", + "stableId": "task-chat-mobile-streaming-follow", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow" + }, + { + "file": "ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx", + "capabilities": [ + "tasks/discussion" + ], + "purpose": "Product/Task chat/System notice", + "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", + "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", + "stableId": "product-task-chat-system-notice", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice" + }, + { + "file": "ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx", + "capabilities": [ + "questions-and-approvals/dismiss-and-reopen", + "tasks/discussion" + ], + "purpose": "Chat & Comments/Task Chat Unanswered Questions", + "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", + "sources": [ + "maps/feature-map/questions-and-approvals.md", + "maps/feature-map/tasks.md", + "ui/src/components/TaskChatThread.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/fixtures/unansweredQuestionChat.tsx", + "ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx" + ], + "fingerprint": "57a0668631cdab3b46f20a1144b367117307fa788114ee3a231e41c9db745dbe", + "stableId": "chat-comments-task-chat-unanswered-questions", + "sidebarTitle": "Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions" + }, + { + "file": "ui/storybook/stories/tasks/task-context-notices.stories.tsx", + "capabilities": [ + "tasks/discussion" + ], + "purpose": "Tasks/Context notices", + "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", + "sources": [ + "maps/feature-map/tasks.md", + "ui/src/components/EmailMessageCard.tsx", + "ui/src/components/ExecutionBlockerNotice.tsx", + "ui/src/components/chat/ExternallyConnectedTaskBanner.tsx", + "ui/storybook/stories/tasks/task-context-notices.stories.tsx" + ], + "fingerprint": "87bfd426fdddcc330f0850b036102dd3f1f023e4c0df26f0519dce43cadd1901", + "stableId": "tasks-context-notices", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Tasks/Context notices" + }, + { + "file": "ui/storybook/stories/steering/task-execution-controls.stories.tsx", + "capabilities": [ + "steering/stop-and-pause" + ], + "purpose": "Composer/Execution controls", + "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", + "sources": [ + "maps/feature-map/steering.md", + "ui/src/components/TaskTreeControls.tsx", + "ui/src/components/ToastViewport.tsx", + "ui/src/components/task-chat/TaskChatComposer.tsx", + "ui/src/components/task-chat/TaskChatMarker.tsx", + "ui/storybook/stories/steering/task-execution-controls.stories.tsx" + ], + "fingerprint": "c0d5b4c251fbdb83e15c7b4dcbffe3da402672c88145053bade66facf45d5fbe", + "stableId": "composer-execution-controls", + "sidebarTitle": "Tasks and conversations/Steering and queued messages/Composer/Execution controls" + }, + { + "file": "ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx", + "capabilities": [ + "documents-artifacts/attachments" + ], + "purpose": "Tasks/Text file tabs", + "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", + "sources": [ + "maps/feature-map/documents-artifacts.md", + "ui/src/components/task-side-panel/TaskSidePanel.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx" + ], + "fingerprint": "1028fe178cec6a8d04100a1a89364cd64ffda5112e5235443ad47e4f4ef0358f", + "stableId": "tasks-text-file-tabs", + "sidebarTitle": "Tasks and conversations/Documents, attachments, and work products/Tasks/Text file tabs" + }, + { + "file": "ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx", + "capabilities": [ + "task-coordination/tree-control" + ], + "purpose": "Product/Task Watchdog surfaces", + "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", + "sources": [ + "maps/feature-map/task-coordination.md", + "ui/src/components/IssueProperties.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx" + ], + "fingerprint": "ac40d01fdf92f09aeeb648ffe0a8304f5ea7aa8b47ab36bf7a62c89a1603a791", + "stableId": "product-task-watchdog-surfaces", + "sidebarTitle": "Tasks and conversations/Delegation, dependencies, and signoff/Product/Task Watchdog surfaces" + }, + { + "file": "ui/storybook/stories/teams/team-catalog.stories.tsx", + "capabilities": [ + "teams/preview", + "teams/discovery" + ], + "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", + "sources": [ + "maps/feature-map/teams.md", + "ui/src/pages/TeamCatalog.fixtures.ts", + "ui/src/pages/TeamCatalog.tsx", + "ui/storybook/stories/teams/team-catalog.stories.tsx" + ], + "fingerprint": "00b449499c3b79649dc1f29b5f105996c79e063b3d03c31226d861ad2954803b", + "stableId": "surfaces-team-catalog", + "sidebarTitle": "Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog" + }, + { + "file": "ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx", + "capabilities": [ + "tasks/discussion" + ], + "purpose": "Chat & Comments/Human identity/00 Review guide", + "status": "unreviewed", + "implementation": "guide", + "boundary": "static", + "notes": "Review guide retained; explanatory content does not establish runtime or production coverage.", + "reviewedAt": "2026-10-12", + "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", + "stableId": "chat-comments-human-identity-00-review-guide", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide" + }, + { + "file": "ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx", + "capabilities": [ + "tasks/discussion" + ], + "purpose": "Chat & Comments/Human identity/01 Components", + "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", + "sources": [ + "maps/feature-map/tasks.md", + "ui/src/components/task-chat/TaskChatBubble.tsx", + "ui/src/components/task-chat/task-chat-adapter.ts", + "ui/src/components/task-chat/task-chat-model.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx", + "ui/storybook/support/thread-human-identity/fixtures.ts" + ], + "fingerprint": "15e8035278a39e51ececfae5601e57dae8046e24aee5ed04949bdc219c9250c0", + "stableId": "chat-comments-human-identity-01-components", + "sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components" + }, + { + "file": "ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx", + "capabilities": [ + "tasks/discussion" + ], + "purpose": "Chat & Comments/Human identity/02 Mixed-author 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", + "sources": [ + "maps/feature-map/tasks.md", + "ui/src/components/Layout.tsx", + "ui/src/components/TaskChatThread.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx", + "ui/storybook/support/thread-human-identity/fixtures.ts" + ], + "fingerprint": "167c4f2d1cfb9c7f0487f8197908342b889d89bb6a0e752b1aac3a3c9ca6d132", + "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" + }, + { + "file": "ui/storybook/stories/secrets/user-secrets.stories.tsx", + "capabilities": [ + "secrets/user-secrets" + ], + "purpose": "Product/User secrets", + "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", + "sources": [ + "maps/feature-map/secrets.md", + "ui/src/components/environment-variables-editor/index.tsx", + "ui/src/pages/secrets/MissingUserSecretsBanner.tsx", + "ui/src/pages/secrets/MyUserSecretsTab.tsx", + "ui/src/pages/secrets/UserSecretDefinitionsTab.tsx", + "ui/storybook/stories/secrets/user-secrets.stories.tsx" + ], + "fingerprint": "5c875865c81923b2c568559d0c2f7bde22d1a8162501736ba3eeeb027acae772", + "stableId": "product-user-secrets", + "sidebarTitle": "Apps, channels, and extensions/Secrets and proposals/Product/User secrets" + }, + { + "file": "ui/storybook/stories/developer-labs/ux-labs.stories.tsx", + "capabilities": [ + "developer-labs/interaction-labs" + ], + "purpose": "UX Labs/Converted Test Pages", + "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", + "sources": [ + "maps/feature-map/developer-labs.md", + "ui/src/pages/InviteUxLab.tsx", + "ui/src/pages/IssueChatUxLab.tsx", + "ui/src/pages/RunTranscriptUxLab.tsx", + "ui/src/pages/SystemNoticeUxLab.tsx", + "ui/storybook/stories/developer-labs/ux-labs.stories.tsx" + ], + "fingerprint": "b8df6cf57fcda25d2b175bc3156d539fb71055b5dae069bca61af8903a01aaf8", + "stableId": "ux-labs-converted-test-pages", + "sidebarTitle": "Contributor surfaces/Developer previews and diagnostic labs/UX Labs/Converted Test Pages" + }, + { + "file": "ui/storybook/stories/inbox-search/what-needs-me.stories.tsx", + "capabilities": [ + "inbox-search/decisions" + ], + "purpose": "Pages/Decisions", + "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", + "sources": [ + "maps/feature-map/inbox-search.md", + "ui/src/components/AttentionQueueRow.tsx", + "ui/src/components/IssueGroupHeader.tsx", + "ui/src/components/ToastViewport.tsx", + "ui/storybook/stories/inbox-search/what-needs-me.stories.tsx" + ], + "fingerprint": "b84aef9b566d476455d6b441adfb301d92f68148a409eff29f5850c047d04baf", + "stableId": "pages-decisions", + "sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions" + }, + { + "file": "ui/storybook/stories/activity/work-timeline.stories.tsx", + "capabilities": [ + "activity/timeline" + ], + "purpose": "Pages/Work Timeline", + "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", + "sources": [ + "maps/feature-map/activity.md", + "ui/src/components/timeline/WorkTimelineChart.tsx", + "ui/src/pages/Timeline.tsx", + "ui/storybook/fixtures/workTimeline.human.sample.json", + "ui/storybook/fixtures/workTimeline.sample.json", + "ui/storybook/stories/activity/work-timeline.stories.tsx" + ], + "fingerprint": "ef4f6622a54c742869916b1bded0fd410aaf1faa3bf9e5ff37182eac7ac6aa50", + "stableId": "pages-work-timeline", + "sidebarTitle": "Oversight and operation/Dashboards and audit trails/Pages/Work Timeline" + }, + { + "file": "ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx", + "capabilities": [ + "recovery/workspace" + ], + "purpose": "Workspace branch recovery/01 Repair card", + "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", + "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": "411a3a9332d5348327cb4c04e508895f9f1989d6da27fe8d1d73ebd34be87a82", + "stableId": "workspace-branch-recovery-01-repair-card", + "sidebarTitle": "Projects and execution/Recovery/Workspace branch recovery/01 Repair card" + }, + { + "file": "ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx", + "capabilities": [ + "recovery/workspace" + ], + "purpose": "Workspace branch recovery/02 Task 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", + "sources": [ + "maps/feature-map/recovery.md", + "ui/src/components/Layout.tsx", + "ui/src/components/StatusBadge.tsx", + "ui/src/components/WorkspaceBaseRefRecoveryNotice.tsx", + "ui/src/components/task-chat/TaskChatBubble.tsx", + "ui/src/components/task-chat/TaskChatComposer.tsx", + "ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx", + "ui/storybook/support/workspace-branch-recovery/fixtures.ts" + ], + "fingerprint": "85db6aeb522937db01d8e307eab632d16f887abe88301c5d2f3c2f797fcb7cec", + "stableId": "workspace-branch-recovery-02-task-journey", + "sidebarTitle": "Projects and execution/Recovery/Workspace branch recovery/02 Task journey" + }, + { + "file": "ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx", + "capabilities": [ + "workspaces/files-git" + ], + "purpose": "Issue/Workspace File Browser", + "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", + "sources": [ + "maps/feature-map/workspaces.md", + "ui/src/components/FileViewerSheet.tsx", + "ui/src/components/IssueWorkspaceCard.tsx", + "ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx" + ], + "fingerprint": "e8ece699c3f156643552e788ba86cedda4f2e3a4e6e1a40fbf8b6bef1103254f", + "stableId": "issue-workspace-file-browser", + "sidebarTitle": "Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser" + }, + { + "file": "ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx", + "capabilities": [ + "workspaces/provisioning" + ], + "purpose": "Workspaces/Runtime provisioning status", + "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", + "sources": [ + "maps/feature-map/workspaces.md", + "ui/src/pages/ExecutionWorkspaceDetail.tsx", + "ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx" + ], + "fingerprint": "4ec295039bb17a9590ab1fb0be2864845a903a625fcaf79459541ddd5a4670f4", + "stableId": "workspaces-runtime-provisioning-status", + "sidebarTitle": "Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status" + }, + { + "file": "ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx", + "capabilities": [ + "workspaces/services" + ], + "purpose": "Workspaces/Service control bar", + "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", + "sources": [ + "maps/feature-map/workspaces.md", + "ui/src/components/WorkspaceServiceControlBar.tsx", + "ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx" + ], + "fingerprint": "cbe4b5d61324f8eec1dfff9583dc84eab5c83947ff947f5c381d00217b29b5e2", + "stableId": "workspaces-service-control-bar", + "sidebarTitle": "Projects and execution/Workspaces, services, and files/Workspaces/Service control bar" + }, + { + "file": "ui/storybook/stories/agents/codex-runner-creation.stories.tsx", + "capabilities": [ + "agents/hire", + "runs-adapters/availability", + "runs-adapters/validation" + ], + "purpose": "Codex runner creation, explicit Legacy, experimental harness links and setup failure", + "status": "unreviewed", + "implementation": "production", + "boundary": "fixtures", + "notes": "Production NewAgent and AgentBasicsDialog with an isolated fetch boundary. Native/Legacy replies and save mutations are simulated; source inspection does not establish all play cases or provider/default qualification. Existing eight story IDs retained through explicit metadata and feature placement. Native adapter inventory is not duplicated when the shared preview provides it.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "maps/feature-map/runs-adapters.md", + "ui/src/components/AgentRunnerSelect.tsx", + "ui/src/components/agent-config-defaults.ts", + "ui/src/components/new-agent/AgentBasicsDialog.tsx", + "ui/src/components/new-agent/AgentProviderConnection.tsx", + "ui/src/components/new-agent/NewAgentSetup.tsx", + "ui/src/lib/new-agent-adapters.ts", + "ui/src/lib/new-agent-runtime-config.ts", + "ui/src/pages/NewAgent.tsx", + "ui/storybook/fixtures/onboardingEnvironment.ts", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agents/codex-runner-creation.stories.tsx" + ], + "fingerprint": "323f32e42c70dd18285f4b8472d000b9bea4d33c3418770d8be0fcdd3db013d7", + "stableId": "agents-codex-runner-creation", + "sidebarTitle": "Agents and reusable capabilities/Hiring, configuration, and organization/Codex runner/Creation" + } + ] +} diff --git a/packages/adapter-utils/src/ssh-command-managed.test.ts b/packages/adapter-utils/src/ssh-command-managed.test.ts index 12d7c7f7788..57ed8b5d618 100644 --- a/packages/adapter-utils/src/ssh-command-managed.test.ts +++ b/packages/adapter-utils/src/ssh-command-managed.test.ts @@ -5,7 +5,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); - // Execute the authored remote command locally, through both subprocess paths. return { ...actual, execFile: vi.fn((file: string, args: string[], options: object, callback: Parameters[3]) => { diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 342ca553bd1..783b5d37a77 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -68,8 +68,6 @@ export function createSshCommandManagedRuntimeRunner(input: { const envPrefix = envEntries.length > 0 ? `env ${envEntries.map(([key, value]) => shellQuote(`${key}=${value}`)).join(" ")} ` : ""; - // Keep sh -c's command name and positional arguments intact. Flattening - // its script loses $0/$1/... and breaks native runner launch/identity. const commandScript = `exec ${envPrefix}${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`; const remoteCommand = `cd ${shellQuote(cwd)} && ${commandScript}`; diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index bad24a8ba1d..2818fd3292e 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -725,7 +725,6 @@ export interface CLIAdapterModule { export interface CreateConfigValues { runner?: "auto" | "paperclip" | "legacy"; - /** Transient form resolution; never an execution setting or API override. */ resolvedRunner?: "paperclip" | "legacy"; adapterType: string; codexPermissionMode?: "never" | "on-request" | "untrusted"; diff --git a/packages/adapters/claude-local/src/ui/build-config.ts b/packages/adapters/claude-local/src/ui/build-config.ts index 072efac710e..c3d452a8e92 100644 --- a/packages/adapters/claude-local/src/ui/build-config.ts +++ b/packages/adapters/claude-local/src/ui/build-config.ts @@ -45,8 +45,6 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; const nativeSelected = v.runner === "paperclip" || ((v.runner === undefined || v.runner === "auto") && v.resolvedRunner === "paperclip"); - // Only a resolved native form omits the Legacy default. Explicit limits - // still reach the server's compatibility check. if (!nativeSelected || v.maxTurnsPerRun !== 1000) ac.maxTurnsPerRun = v.maxTurnsPerRun; if (nativeSelected && v.adapterSchemaValues?.acpxPermissionMode !== undefined) { ac.acpxPermissionMode = v.adapterSchemaValues.acpxPermissionMode; diff --git a/packages/db/src/migrations/0335_absurd_bulldozer.sql b/packages/db/src/migrations/0335_absurd_bulldozer.sql new file mode 100644 index 00000000000..56224dbe057 --- /dev/null +++ b/packages/db/src/migrations/0335_absurd_bulldozer.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS "chat_slack_manager_grants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_id" text NOT NULL, + "slack_user_id" text NOT NULL, + "workspace_id" text NOT NULL, + "workspace_name" text NOT NULL, + "manager_app_id" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "access_secret_id" uuid, + "refresh_secret_id" uuid, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "chat_slack_manager_grants_status_check" CHECK ("chat_slack_manager_grants"."status" in ('active', 'revoked', 'reauthorize')) +); + +--> statement-breakpoint + +ALTER TABLE "chat_slack_registrations" ADD COLUMN IF NOT EXISTS "manager_grant_id" uuid; +--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "chat_slack_manager_grants" ADD CONSTRAINT "chat_slack_manager_grants_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; +--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_slack_manager_grants_company_id_idx" ON "chat_slack_manager_grants" USING btree ("company_id","id"); +--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_slack_manager_grants_owner_idx" ON "chat_slack_manager_grants" USING btree ("company_id","user_id","manager_app_id","workspace_id"); +--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "chat_slack_registrations" ADD CONSTRAINT "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk" FOREIGN KEY ("company_id","manager_grant_id") REFERENCES "public"."chat_slack_manager_grants"("company_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; +--> statement-breakpoint +ALTER TABLE "chat_slack_manager_grants" ADD COLUMN IF NOT EXISTS "revision" integer DEFAULT 0 NOT NULL; +--> statement-breakpoint + +ALTER TABLE "chat_slack_manager_grants" ADD COLUMN IF NOT EXISTS "rate_limits" jsonb DEFAULT '{}'::jsonb NOT NULL; diff --git a/packages/db/src/migrations/meta/0330_snapshot.json b/packages/db/src/migrations/meta/0335_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0330_snapshot.json rename to packages/db/src/migrations/meta/0335_snapshot.json index 460bb97a1cf..b348334bbd3 100644 --- a/packages/db/src/migrations/meta/0330_snapshot.json +++ b/packages/db/src/migrations/meta/0335_snapshot.json @@ -1,6 +1,6 @@ { - "id": "0a93bc99-bf0c-4ea2-ab27-6043b6746cd5", - "prevId": "76861d12-d9a9-4f5f-bcbc-0fb06c0addbc", + "id": "18c156f2-8344-4282-98b7-f702f0035c81", + "prevId": "39d82f2c-8fd5-4606-84df-f51880dbd6a7", "version": "7", "dialect": "postgresql", "tables": { @@ -416,6 +416,12 @@ "primaryKey": false, "notNull": false }, + "fast_response_request_id": { + "name": "fast_response_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "agent_id": { "name": "agent_id", "type": "uuid", @@ -462,6 +468,27 @@ } }, "indexes": { + "budget_reservations_fast_response_idx": { + "name": "budget_reservations_fast_response_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fast_response_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, "budget_reservations_decision_idx": { "name": "budget_reservations_decision_idx", "columns": [ @@ -580,7 +607,7 @@ "checkConstraints": { "budget_reservations_source_check": { "name": "budget_reservations_source_check", - "value": "(\"budget_reservations\".\"run_id\" is not null)::int + (\"budget_reservations\".\"decision_invocation_id\" is not null)::int = 1" + "value": "(\"budget_reservations\".\"run_id\" is not null)::int + (\"budget_reservations\".\"decision_invocation_id\" is not null)::int + (\"budget_reservations\".\"fast_response_request_id\" is not null)::int = 1" }, "budget_reservations_state_check": { "name": "budget_reservations_state_check", @@ -3737,6 +3764,28 @@ "method": "btree", "with": {} }, + "agent_wakeup_requests_question_response_history_idx": { + "name": "agent_wakeup_requests_question_response_history_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'question-response:%'", + "concurrently": true, + "method": "btree", + "with": {} + }, "agent_wakeup_requests_connection_intent_delivery_idempotency_uq": { "name": "agent_wakeup_requests_connection_intent_delivery_idempotency_uq", "columns": [ @@ -12384,6 +12433,189 @@ }, "isRLSEnabled": false }, + "public.chat_slack_manager_grants": { + "name": "chat_slack_manager_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_app_id": { + "name": "manager_app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "access_secret_id": { + "name": "access_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "refresh_secret_id": { + "name": "refresh_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rate_limits": { + "name": "rate_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_slack_manager_grants_company_id_idx": { + "name": "chat_slack_manager_grants_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_slack_manager_grants_owner_idx": { + "name": "chat_slack_manager_grants_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "manager_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_slack_manager_grants_company_id_companies_id_fk": { + "name": "chat_slack_manager_grants_company_id_companies_id_fk", + "tableFrom": "chat_slack_manager_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_slack_manager_grants_status_check": { + "name": "chat_slack_manager_grants_status_check", + "value": "\"chat_slack_manager_grants\".\"status\" in ('active', 'revoked', 'reauthorize')" + } + }, + "isRLSEnabled": false + }, "public.chat_slack_registrations": { "name": "chat_slack_registrations", "schema": "", @@ -12400,6 +12632,12 @@ "primaryKey": false, "notNull": true }, + "manager_grant_id": { + "name": "manager_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "request_id": { "name": "request_id", "type": "uuid", @@ -12490,6 +12728,21 @@ }, "indexes": {}, "foreignKeys": { + "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk": { + "name": "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk", + "tableFrom": "chat_slack_registrations", + "tableTo": "chat_slack_manager_grants", + "columnsFrom": [ + "company_id", + "manager_grant_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_slack_registrations", @@ -16725,6 +16978,19 @@ "primaryKey": false, "notNull": false }, + "package_mode": { + "name": "package_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skills'" + }, + "repository_package": { + "name": "repository_package", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "excluded_folders": { "name": "excluded_folders", "type": "jsonb", @@ -17052,6 +17318,87 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_skill_repository_snapshots": { + "name": "company_skill_repository_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skill_repository_snapshots_content_idx": { + "name": "company_skill_repository_snapshots_content_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_repository_snapshots_company_id_companies_id_fk": { + "name": "company_skill_repository_snapshots_company_id_companies_id_fk", + "tableFrom": "company_skill_repository_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.company_skill_stars": { "name": "company_skill_stars", "schema": "", @@ -17906,6 +18253,18 @@ "primaryKey": false, "notNull": true }, + "repository_snapshot_id": { + "name": "repository_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_skill_path": { + "name": "repository_skill_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, "revision_number": { "name": "revision_number", "type": "integer", @@ -18062,6 +18421,19 @@ "onDelete": "cascade", "onUpdate": "no action" }, + "company_skill_versions_repository_snapshot_id_company_skill_repository_snapshots_id_fk": { + "name": "company_skill_versions_repository_snapshot_id_company_skill_repository_snapshots_id_fk", + "tableFrom": "company_skill_versions", + "tableTo": "company_skill_repository_snapshots", + "columnsFrom": [ + "repository_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, "company_skill_versions_author_agent_id_agents_id_fk": { "name": "company_skill_versions_author_agent_id_agents_id_fk", "tableFrom": "company_skill_versions", @@ -19576,6 +19948,34 @@ "method": "btree", "with": {} }, + "cost_events_data_quality_idx": { + "name": "cost_events_data_quality_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cost_events\".\"billing_type\" = 'unknown' or (\"cost_events\".\"cost_status\" = 'unpriced' and \"cost_events\".\"billing_type\" <> 'subscription_included')", + "concurrently": false, + "method": "btree", + "with": {} + }, "cost_events_company_occurred_idx": { "name": "cost_events_company_occurred_idx", "columns": [ @@ -19826,7 +20226,7 @@ "checkConstraints": { "cost_events_usage_kind_check": { "name": "cost_events_usage_kind_check", - "value": "\"cost_events\".\"usage_kind\" = 'decision' or (\"cost_events\".\"usage_kind\" = 'agent' and \"cost_events\".\"agent_id\" is not null)" + "value": "\"cost_events\".\"usage_kind\" in ('decision', 'fast_response') or (\"cost_events\".\"usage_kind\" = 'agent' and \"cost_events\".\"agent_id\" is not null)" }, "cost_events_nonnegative_amounts": { "name": "cost_events_nonnegative_amounts", @@ -27491,6 +27891,378 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_fast_responses": { + "name": "company_fast_responses", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_sponsored": { + "name": "allow_sponsored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "company_fast_responses_company_id_companies_id_fk": { + "name": "company_fast_responses_company_id_companies_id_fk", + "tableFrom": "company_fast_responses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "company_fast_responses_configured": { + "name": "company_fast_responses_configured", + "value": "(\"company_fast_responses\".\"connection_id\" is null) = (\"company_fast_responses\".\"grant_id\" is null) and (not \"company_fast_responses\".\"enabled\" or (\"company_fast_responses\".\"connection_id\" is not null and \"company_fast_responses\".\"model\" is not null))" + } + }, + "isRLSEnabled": false + }, + "public.fast_response_requests": { + "name": "fast_response_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_comment_id": { + "name": "source_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sponsored": { + "name": "sponsored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_generation": { + "name": "session_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "publication_status": { + "name": "publication_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment_id": { + "name": "comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "fast_response_requests_source_idx": { + "name": "fast_response_requests_source_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_response_requests_history_idx": { + "name": "fast_response_requests_history_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accepted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_response_requests_work_idx": { + "name": "fast_response_requests_work_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"fast_response_requests\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_response_requests_company_id_companies_id_fk": { + "name": "fast_response_requests_company_id_companies_id_fk", + "tableFrom": "fast_response_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fast_response_requests_cost_event_id_cost_events_id_fk": { + "name": "fast_response_requests_cost_event_id_cost_events_id_fk", + "tableFrom": "fast_response_requests", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "fast_response_requests_status_check": { + "name": "fast_response_requests_status_check", + "value": "\"fast_response_requests\".\"status\" in ('pending', 'running', 'succeeded', 'failed', 'unknown', 'skipped')" + } + }, + "isRLSEnabled": false + }, "public.feedback_exports": { "name": "feedback_exports", "schema": "", @@ -29938,6 +30710,34 @@ "method": "btree", "with": {} }, + "heartbeat_runs_cost_data_quality_idx": { + "name": "heartbeat_runs_cost_data_quality_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"finished_at\", \"created_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_runs\".\"cost_accounting_pending\" = true and \"heartbeat_runs\".\"status\" in ('succeeded','failed','timed_out','cancelled','interrupted')", + "concurrently": false, + "method": "btree", + "with": {} + }, "heartbeat_runs_execution_status_delivery_idx": { "name": "heartbeat_runs_execution_status_delivery_idx", "columns": [ @@ -32392,6 +33192,19 @@ "primaryKey": false, "notNull": false }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'comment'" + }, + "fast_response_request_id": { + "name": "fast_response_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "body": { "name": "body", "type": "text", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 4b1ef9079c1..22c832f0017 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2332,6 +2332,13 @@ "when": 1791807836288, "tag": "0334_rare_slipstream", "breakpoints": true + }, + { + "idx": 335, + "version": "7", + "when": 1791832982875, + "tag": "0335_absurd_bulldozer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/chat_slack_manager_grants.ts b/packages/db/src/schema/chat_slack_manager_grants.ts new file mode 100644 index 00000000000..fb4d5ef8861 --- /dev/null +++ b/packages/db/src/schema/chat_slack_manager_grants.ts @@ -0,0 +1,26 @@ +import { sql } from "drizzle-orm"; +import { check, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +/** Reusable personal management authorization; never a bot runtime credential. */ +export const chatSlackManagerGrants = pgTable("chat_slack_manager_grants", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + slackUserId: text("slack_user_id").notNull(), + workspaceId: text("workspace_id").notNull(), + workspaceName: text("workspace_name").notNull(), + managerAppId: text("manager_app_id").notNull(), + status: text("status").$type<"active" | "revoked" | "reauthorize">().notNull().default("active"), + accessSecretId: uuid("access_secret_id"), + refreshSecretId: uuid("refresh_secret_id"), + revision: integer("revision").notNull().default(0), + rateLimits: jsonb("rate_limits").$type>().notNull().default({}), + expiresAt: timestamp("expires_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, t => [ + uniqueIndex("chat_slack_manager_grants_company_id_idx").on(t.companyId, t.id), + uniqueIndex("chat_slack_manager_grants_owner_idx").on(t.companyId, t.userId, t.managerAppId, t.workspaceId), + check("chat_slack_manager_grants_status_check", sql`${t.status} in ('active', 'revoked', 'reauthorize')`), +]); diff --git a/packages/db/src/schema/chat_slack_registrations.ts b/packages/db/src/schema/chat_slack_registrations.ts index 0edad4a8917..5d17b660bd0 100644 --- a/packages/db/src/schema/chat_slack_registrations.ts +++ b/packages/db/src/schema/chat_slack_registrations.ts @@ -1,12 +1,14 @@ import { sql } from "drizzle-orm"; import { check, foreignKey, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import type { SlackRegistrationStatus } from "@paperclipai/shared"; +import { chatSlackManagerGrants } from "./chat_slack_manager_grants.js"; import { chatEndpoints } from "./chat_channels.js"; /** App provisioning is distinct from installation and runtime credentials. No plaintext secrets. */ export const chatSlackRegistrations = pgTable("chat_slack_registrations", { endpointId: uuid("endpoint_id").primaryKey(), companyId: uuid("company_id").notNull(), + managerGrantId: uuid("manager_grant_id"), requestId: uuid("request_id").notNull(), status: text("status").$type().notNull(), manifest: jsonb("manifest").$type>().notNull(), @@ -22,6 +24,7 @@ export const chatSlackRegistrations = pgTable("chat_slack_registrations", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, t => [ + foreignKey({ columns: [t.companyId, t.managerGrantId], foreignColumns: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.id] }), foreignKey({ columns: [t.companyId, t.endpointId], foreignColumns: [chatEndpoints.companyId, chatEndpoints.id] }).onDelete("cascade"), check("chat_slack_registration_status_check", sql`${t.status} in ('creating', 'uncertain', 'failed', 'install', 'credentials_saved', 'configured', 'removed')`), ]); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 4c4bad38337..630a0869b55 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -243,6 +243,8 @@ export { chatVoiceReports } from "./chat_voice_reports.js"; export { chatVoicePhoneLines, chatVoiceInboundCalls } from "./chat_voice_inbound.js"; export * from "./decision_models.js"; export { userCompanyPreferences } from "./user_company_preferences.js"; + +export { chatSlackManagerGrants } from "./chat_slack_manager_grants.js"; export { aiSubscriptions, aiSubscriptionPrices, aiSubscriptionConnections } from "./ai_subscriptions.js"; export * from "./fast_responses.js"; diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 289c782947e..a26968b621e 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -14,12 +14,12 @@ "requiresProviderPolicy": false }, "pi": { - "agentProfileVersion": 24, + "agentProfileVersion": 25, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b", + "commandDigest": "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a", "requiresProviderPolicy": true }, "cursor": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 6c28264f235..cfc5f9d8a1c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -3200,7 +3200,7 @@ mod tests { #[test] fn pi_profile_matches_published_identity_and_rejects_prior_profiles() { let published: Value = serde_json::from_str(include_str!( - "../../../../test-fixtures/pi-acp/profile-v24-identity.json" + "../../../../test-fixtures/pi-acp/profile-v25-identity.json" )) .unwrap(); let mut value = descriptor("codex"); @@ -3217,6 +3217,7 @@ mod tests { current.validate(&context()).unwrap(); for prior in [ + include_str!("../../../../test-fixtures/pi-acp/profile-v24-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v23-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v15-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v16-identity.json"), diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 99f4d02739b..5498dbfe2d9 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -29,7 +29,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: Some("@earendil-works/pi-coding-agent"), agent_runtime_version: Some("1.0.0"), command_digest: - "sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b", + "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a", requires_provider_policy: true, }, "cursor" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 22630885bfc..39143816c96 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"; const PI_ACPX_DIGEST: &str = - "sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b"; + "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs index 97ebd13de7a..ab6153b7ce7 100644 --- a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs +++ b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs @@ -1,4 +1,3 @@ -// Assemble the four independently built release daemons before npm packing. // Usage: node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs MANIFEST.json // Manifest: {sourceRevision, platforms: {"darwin-arm64": {path, sha256}, ...}, remoteProviderPack: {path, sha256}}. import { createHash } from "node:crypto"; @@ -32,8 +31,6 @@ function regularFile(path, maximumSize) { return readFileSync(path); } -// Production TypeScript contracts are built by the existing producer step. -// Foreign artifacts are data: the assembler never executes another target. async function metadataContract() { const { PAPERCLIP_RUNNER_BUILD_METADATA } = await import("../dist/evals/build-metadata.js"); const { parsePaperclipRunnerdBuildMetadata } = await import("../dist/evals/runnerd-artifact.js"); @@ -143,8 +140,6 @@ async function main() { for (const artifact of verified) { const relative = `${artifact.target}/paperclip-runnerd`, destination = join(root, "dist", "bin", relative); mkdirSync(dirname(destination), { recursive: true }); copyFileSync(artifact.source, destination); chmodSync(destination, 0o755); - // Copied Mach-O inodes need a fresh ad-hoc signature on macOS. Record the - // resulting packaged bytes, as well as the verified build artifact identity. if (process.platform === "darwin" && artifact.target.startsWith("darwin-")) { execFileSync("codesign", ["--force", "--sign", "-", destination], { stdio: "pipe", timeout: 30_000 }); } @@ -155,7 +150,6 @@ async function main() { if (!pack.bytes) continue; const destination = join(root, "dist", "remote-provider-packs", target, "provider-pack.json"); mkdirSync(dirname(destination), { recursive: true }); - // Replace the inode so repeat assembly can preserve a read-only manifest. const staged = `${destination}.staging-${process.pid}`; try { writeFileSync(staged, pack.bytes, { mode: 0o444, flag: "wx" }); diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.mjs index 581ee2867e8..6b415d58db5 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.mjs @@ -32,8 +32,6 @@ export async function stageBuiltRunnerBinary(packageRoot) { const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; const source = path.join(packageRoot, "runner", "target", "release", executable); const binaryRoot = path.join(packageRoot, "dist", "bin"); - // Source builds are separate from the source-bound release targets and their - // manifest. The dev supervisor selects this fresh output for its server. await stageRunnerBinary(source, path.join(binaryRoot, executable)); } diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs index 9c0c5656379..ddffc53e4c8 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -140,8 +140,6 @@ test("a source rebuild selects fresh dev bytes while preserving release binaries const copyHostFixture = async (input, output) => { await copyFile(input, output); if (process.platform === "darwin") { - // System fixtures can be universal; Cargo release daemons are thin. - // Extract only this host slice before exercising the native header gate. const slices = execFileSync("/usr/bin/lipo", ["-archs", input], { encoding: "utf8" }).trim().split(/\s+/); const architecture = slices.find(value => process.arch === "arm64" ? value.startsWith("arm64") : value === "x86_64"); assert.ok(architecture); diff --git a/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts b/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts index 504ddd08afe..e8b048a53b5 100644 --- a/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts +++ b/packages/paperclip-runner/src/drivers/acpx/claude-effort.ts @@ -8,8 +8,6 @@ export function resolveClaudeEffort(agent: string, value: unknown): ClaudeEffort return value; } export function createClaudeEffortAdmission(expected: ClaudeEffort) { - // Every connection, including session/load, must select and acknowledge the - // configured effort. A restored preference alone never admits a prompt. return createAcpxConfigAdmission(expected, { label: "Claude effort", configId: "effort", supportedValues: ["default", "low", "medium", "high", "xhigh", "max"] }); } diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts index c13ffcb1596..f1ac121755d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts @@ -1699,9 +1699,6 @@ async function silentPrimaryQuorumFixture( if (failure?.status === "rejected") throw failure.reason; }, }; - // Only fixture preparation may retry. Release the unselected candidates - // immediately before the caller stages once. A foreign bind racing this - // handoff remains a visible production-call failure, never a hidden retry. const released = await Promise.allSettled( owned .filter((_, index) => !occupiedIndices.includes(index)) diff --git a/packages/paperclip-runner/src/drivers/acpx/config-admission.ts b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts index be597f10e16..73e5faf73bc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/config-admission.ts +++ b/packages/paperclip-runner/src/drivers/acpx/config-admission.ts @@ -2,8 +2,6 @@ function object(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } -/** Native mode is independent of permissions. Every connection must prove its - * current mode before a prompt; a persisted ACPX preference is not proof. */ export function createAcpxConfigAdmission(expected: string, options: { label: string; configId: string; supportedValues: readonly string[]; restoring?: boolean; sessionMode?: boolean }) { type State = { sessionId: string | null; mode: string | null; error: Error | null }; let current: State | null = null; @@ -17,8 +15,6 @@ export function createAcpxConfigAdmission(expected: string, options: { label: st const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === options.configId) : []; if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous"); const mode = object(configs[0]).currentValue; - // Before exact model selection, Pi can legitimately report a different - // native model's level. Observation alone never admits the first prompt. if (typeof mode !== "string" || !options.supportedValues.includes(mode)) throw failure("native mode is unsupported"); if (session && options.sessionMode && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict"); return mode; @@ -56,16 +52,12 @@ export function createAcpxConfigAdmission(expected: string, options: { label: st pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === options.configId }); } else if (options.sessionMode && method === "session/set_mode") { if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode"); - // This method only returns {}; require a later config/session - // acknowledgement rather than treating that empty result as proof. state.mode = null; } return; } if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") { const update = object(params.update); - // Config updates may contain only model options. A mode entry is - // authoritative observation, but never an admission acknowledgement. if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === options.configId)) { if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session"); const observed = readMode(update, false); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 20b368c2ab4..ecc8eea459e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -22,12 +22,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "pi", - "agentProfileVersion": 24, + "agentProfileVersion": 25, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:d25d674cda9a0853bdbf0521025099f1424c573d2f533630cb67d90e1bfcda3b", + "commandDigest": "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a", "permissionPolicy": "interactive" }, "cursor": { diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index b3a848ec8c0..640bfb7b954 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -104,11 +104,19 @@ describe("Pi installation factory", () => { expect(current.commandDigest).not.toBe(prior.commandDigest); }); + it("retains profile 24 while binding the comment-free sandbox source in profile 25", async () => { + const read = async (version: number) => JSON.parse(await readFile(new URL(`../../../test-fixtures/pi-acp/profile-v${version}-identity.json`, import.meta.url), "utf8")); + const prior = await read(24), current = await read(25); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "runtimeSandboxSourceSha256"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + it("binds the profile declaration to the reviewed patch and platform closure pins", async () => { const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value); - const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v24-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v25-identity.json", import.meta.url), "utf8")); const declaration = identity.declaration; expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`); @@ -139,21 +147,21 @@ describe("Pi installation factory", () => { it("rejects legacy profiles and tampered executable identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 24"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 24"); - for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 25"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 25"); + for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23, 24]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 25"); for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) { expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 9bd4402b9be..fe353e6a6f7 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -483,8 +483,6 @@ export class AcpxRuntimeHost { }); } options.signal?.throwIfAborted(); - // Await this write under ownership even if cancellation arrives. Releasing - // the lease before it settles could let a late write affect the next owner. await sandbox.initializeOwnedConfiguration?.(); options.signal?.throwIfAborted(); assertAcpxProfileEnvironment(options.agent, sandbox.launchEnvironment); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 6b315ca4470..aebebe86c5c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -53,7 +53,6 @@ export interface AcpxRuntimeSandbox { cacheDirectory: string; agentHomeDirectory: string; workspaceRecordPath: string; - /** Mutate provider settings only while the host holds its lifetime lease. */ initializeOwnedConfiguration?: () => Promise; launchEnvironment: Readonly; persistedEnvironment: Readonly; @@ -405,9 +404,6 @@ export async function prepareAcpxRuntimeSandbox(input: { `${JSON.stringify({ model: input.binding.requestedModel, availableModels: [input.binding.requestedModel], - // Claude reloads effort from settings on a new ACP connection. Keep - // the bound choice available during model verification and resume; - // the protocol guard still requires a provider acknowledgement. ...(input.binding.claudeEffort ? { effortLevel: input.binding.claudeEffort } : {}), ...((input.binding.permissionMode === "approve-reads" || input.binding.permissionMode === "approve-paperclip") ? { permissions: { allow: claudePaperclipPermissionRules(input.tools ?? [], input.binding.permissionMode) } } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts index 02d377d0451..e8ef76d4d72 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts @@ -31,8 +31,6 @@ vi.mock("smol-toml", async (importOriginal) => { ...actual, parse: (...args: Parameters) => { const result = actual.parse(...args); - // Exercise both table representations independently of the installed - // parser version while retaining the real TOML values. setTablePrototypes(result); return result; }, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts index 488b025148d..fb748ae1697 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts @@ -92,8 +92,6 @@ export function trustCodexStartupRoot(codexHome: string, cwd: string): void { const path = join(codexHome, "config.toml"); const source = existsSync(path) ? readFileSync(path, "utf8") : ""; const config = parse(source); - // Retain the parser's table prototypes so strict candidate comparison also - // works with parsers that return null-prototype TOML tables. const projects = config.projects ?? parse(""); if ( typeof projects !== "object" || diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts index 6e050568d29..9292aa316ab 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -1766,9 +1766,6 @@ describe("Capability live runnerd and Codex session", () => { expect((await store.load(replacement.id))?.status).toBe("suspended"); }); - // This case includes a deliberate 500 ms killed turn, a resumed turn with - // its own 5-second bound, and durable writes. The enclosing test must not - // expire at the resumed turn's deadline before those assertions can finish. it("durably resumes a distinct attempt, preserves partial usage, and deduplicates the semantic effect", async () => { const state = providerState(); const directory = await mkdtemp(join(tmpdir(), "capability-live-durable-")); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index f9cd2051838..65b75bea7d2 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -396,7 +396,6 @@ it("publishes spawned runner ownership before waiting for provider startup", asy }); const opening = bundle.transport.request("thread/start", { cwd: tmpdir(), dynamicTools: codexSemanticToolSpecs() }); try { - // Same-tick recovery must not mutate an already admitted fresh startup. await expect(bundle.transport.request("thread/read", { threadId: "rejected-duplicate" })) .rejects.toThrow("PRP provider thread is already started"); await vi.waitFor(() => expect(onSpawn).toHaveBeenCalledOnce()); @@ -2223,7 +2222,6 @@ it("owns blocked Codex home preparation through driver abort without later regis markPreparationStarted(); await preparationReleased; preparationContinuedAfterClose = closeSettled; - // A synthetic, credential-free write makes the owned preparation visible. await mkdir(input.codexHome, { mode: 0o700 }); await writeFile(join(input.codexHome, "preparation-marker"), "fixture preparation drained\n", { mode: 0o600 }); }); @@ -2264,7 +2262,6 @@ it("owns blocked Codex home preparation through driver abort without later regis .then(value => { openingSettled = true; return { value }; }, error => { openingSettled = true; return { error }; }); try { await preparationStarted; - // A rejected duplicate must not replace the first admitted startup owner. await expect(transport.request("thread/start", { cwd: root, dynamicTools: [] })) .rejects.toThrow("PRP provider thread is already started"); controller.abort(cancelled); @@ -2863,8 +2860,6 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper runtimeDirectory, provider: "codex", model: "gpt-test", timeoutMs: 1_000, environment: { PATH: "", OPENAI_API_KEY: "inert-test-key" }, transportOptions: { - // Registration and launch are external owners; hash real inert bytes - // without executing a daemon, Codex, or an ambient developer binary. runnerBinary: resolve(import.meta.dirname, "../../package.json"), codexCommand: resolve(import.meta.dirname, "../../package.json"), sourceCodexHome: "", runnerProcessLauncher: launch, @@ -2895,8 +2890,6 @@ it.each(["stuck", "late-release", "late-release-error", "stuck-release", "cooper expect(cleanup).not.toHaveBeenCalled(); expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); if (mode === "late-release-error") expect((failure as AggregateError).errors).toContain(releaseError); - // A registration that finishes after the bounded return still releases - // its authority, without retroactively certifying or deleting state. returnRegistration(registered); finishRelease(); await vi.advanceTimersByTimeAsync(0); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 471686d4d43..98a8a253405 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1338,7 +1338,6 @@ export interface CapabilityRunnerdCodexTransportOptions { controlPlaneRegistration?: ( authority: DurablePrpControlPlane, identity?: DurableRecoveryIdentity, - /** Setup owners abort preparation before draining or releasing its result. */ signal?: AbortSignal, ) => Promise<{ connectUrl?: string; @@ -4466,8 +4465,6 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } #trackStartup(start: () => Promise): Promise { - // Admit one owner before invoking startup. A duplicate request's rejection - // must never replace the preparation that close is responsible for joining. if (this.#startupOperation !== null) { return Promise.reject(new Error("PRP provider thread is already started")); } @@ -7108,8 +7105,6 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } await Promise.race([ ready(), this.#failureSignal, - // Readiness is a notification wait, not launch-material preparation. - // Its route owner is released by close; joining it first would cycle. this.#closedSignal.then(() => { this.#assertStartupOpen(); }), ]); this.#assertStartupOpen(); @@ -7160,7 +7155,6 @@ export function createCapabilityRunnerdCodexTransport( export const createRunnerdCodexTransport = createCapabilityRunnerdCodexTransport; -/** A setup turn uses the same daemon/provider boundary as ordinary native runs. */ export async function probeNativeRunnerEnvironment(options: { runtimeDirectory: string; provider: "codex"; @@ -7168,7 +7162,6 @@ export async function probeNativeRunnerEnvironment(options: { reasoningEffort?: string; environment: NodeJS.ProcessEnv; timeoutMs?: number; - /** The selected remote filesystem supplies this cwd; never inspect it on the controller. */ workingDirectory?: string; transportOptions?: CapabilityRunnerdCodexTransportOptions; onCodexCredentialRefresh?: (filename: string) => Promise; @@ -7189,9 +7182,6 @@ export async function probeNativeRunnerEnvironment(options: { let registrationOperation: Promise | undefined; let registrationCleanupConfirmed = true; const sourceCodexHome = options.transportOptions?.sourceCodexHome ?? resolveSourceCodexHome(environment); - // Never let an unbound home or an external-sandbox flag admit host state or - // weaken this read-only probe. The normal materializer copies bound auth and - // the managed provider projection into a new daemon-owned Codex home. delete environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX; environment.HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "probe-home"); environment.CODEX_HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home"); @@ -7310,8 +7300,6 @@ export async function probeNativeRunnerEnvironment(options: { } finally { clearTimeout(timer); controller.abort(failure ?? new Error("Native provider hello probe complete.")); - // Preserve private recovery state if the production close cannot confirm - // teardown; callers remove it only after an authoritative receipt. const teardownErrors: unknown[] = []; const teardownDeadline = Date.now() + 10_000; const awaitTeardown = async (pending: Promise, detail: string): Promise => { diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json new file mode 100644 index 00000000000..8c3e9a4f4cd --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v25-identity.json @@ -0,0 +1,50 @@ +{ + "commandDigest": "sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 25, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf", + "darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e", + "linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410" + }, + "wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + "helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", + "extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v4-root-field-types", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "d5b4c1a426afc85f040c9b55358d1d645299586dc8cec72d1be2509187ca3865", + "credentialEnvironmentSourceSha256": "f8dc40ea1aa894bd44754a35a8f65fbcaa2f4c7ffc88cc5fdaa965e0d378d9bc", + "runtimeSandboxSourceSha256": "144d62b3415cfedf272b5b052e14af1e41fe50a644af84247fc9c20c7ef7edde", + "recoveryIdentitySourceSha256": "8479e04da1ae2f7742e34f045b0270b99e9c3ae39a5b8fb9f164e5776d83224c", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814" + } +} diff --git a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts index 368b574e91b..319fc47cc3e 100644 --- a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts +++ b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts @@ -441,8 +441,6 @@ beforeEach(() => { "div", { key: routine.key }, createElement("span", null, routine.title), - createElement("button", null, "Run now"), - createElement("button", { role: "switch" }, "On"), routine.href ? createElement("a", { href: `/PAP${routine.href}` }, "Configure") : null, routine.defaultDrift?.changedFields.length ? createElement("span", null, `Plugin defaults changed: ${routine.defaultDrift.changedFields.join(", ")}`) @@ -656,6 +654,46 @@ function mockPersistedWikiSpace(harness: ReturnType, s }; } +function renderWikiPage(): string { + return renderToStaticMarkup(createElement(WikiPage, { + context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, + } as never)); +} + +function captureWikiWrites(harness: ReturnType) { + const writes: Array<{ path: string; contents: string }> = []; + harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { + writes.push({ path: relativePath, contents }); + return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); + }; + return writes; +} + +function managedSpaceRow(created: { + space: { id: string; companyId: string; wikiId: string; slug: string; displayName: string; pathPrefix: string | null }; +}): Record { + return { + id: created.space.id, + company_id: created.space.companyId, + wiki_id: created.space.wikiId, + slug: created.space.slug, + display_name: created.space.displayName, + space_type: "local_folder", + folder_mode: "managed_subfolder", + root_folder_key: "wiki-root", + path_prefix: created.space.pathPrefix, + configured_root_path: null, + access_scope: "shared", + owner_user_id: null, + owner_agent_id: null, + team_key: null, + settings: {}, + status: "active", + created_at: null, + updated_at: null, + }; +} + describe("LLM Wiki plugin scaffold", () => { it("declares standalone plugin surfaces without core wiki coupling", () => { expect(manifest.id).toBe("paperclipai.plugin-llm-wiki"); @@ -814,9 +852,7 @@ describe("LLM Wiki plugin scaffold", () => { it("routes legacy Wiki operations URLs to the History issue table", () => { mockPathname = "/PAP/wiki/operations"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Issues table · project-1 · plugin:paperclipai.plugin-llm-wiki:operation"); expect(markup).not.toContain("Recent runs"); @@ -825,9 +861,7 @@ describe("LLM Wiki plugin scaffold", () => { it("loads AGENTS.md from the Wiki page and exposes an edit affordance", () => { mockPathname = "/PAP/wiki/page/templates/AGENTS.md"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("AGENTS"); expect(markup).toContain(">AGENTS.md"); @@ -851,9 +885,7 @@ describe("LLM Wiki plugin scaffold", () => { sourceRefs: [{ kind: "issue", issueIdentifier: "PAP-FOOTER-1" }], }; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Current project state."); expect(markup).not.toContain(">Sources { mockPathname = "/PAP/wiki/settings/maintainer"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Wiki Maintainer"); expect(markup).toContain("Adapter: claude local"); @@ -1024,9 +1048,7 @@ Duplicate headings receive stable suffixes. details: { name: "Wiki Maintainer", status: "pending_approval", adapterType: "claude_local", icon: "book-open", urlKey: "wiki-maintainer" }, }; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("pending approval"); expect(markup).toContain("Approve the agent"); @@ -1035,9 +1057,7 @@ Duplicate headings receive stable suffixes. it("renders root settings as a compact health checklist with the shared path picker", () => { mockPathname = "/PAP/wiki/settings"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain(">Setup { mockPathname = "/PAP/wiki/settings/project"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Use existing project"); expect(markup).toContain("LLM Wiki"); @@ -1199,9 +1211,7 @@ Duplicate headings receive stable suffixes. it("renders space settings with health line styling and disabled access controls", () => { mockPathname = "/PAP/wiki/settings/spaces/default"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Space folder health checklist"); expect(markup).toContain("left:8px"); @@ -1231,9 +1241,7 @@ Duplicate headings receive stable suffixes. runs: [], }; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Agent execution"); expect(markup).toContain("Assigned maintainer"); @@ -1248,7 +1256,7 @@ Duplicate headings receive stable suffixes. expect(markup).not.toContain("Monthly plugin cap"); }); - it("renders managed routines as normal routine rows with run, toggle, and configure controls", () => { + it("passes managed routine titles, bindings, and configuration links to the host list", () => { mockPathname = "/PAP/wiki/settings/routines"; mockSettingsManagedRoutines = [{ status: "resolved", @@ -1265,14 +1273,10 @@ Duplicate headings receive stable suffixes. }, details: { cronExpression: "0 3 * * *" }, }]; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Run LLM Wiki lint"); - expect(markup).toContain("Run now"); expect(markup).toContain("Configure"); - expect(markup).toContain("role=\"switch\""); expect(markup).toContain("LLM Wiki · Wiki Maintainer"); expect(markup).toContain("href=\"/PAP/routines/routine-1\""); }); @@ -1299,9 +1303,7 @@ Duplicate headings receive stable suffixes. details: { cronExpression: "0 3 * * *" }, }]; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Routine defaults changed"); expect(markup).toContain("Plugin defaults changed: description"); @@ -1319,9 +1321,7 @@ Duplicate headings receive stable suffixes. details: { name: "Wiki Maintainer", status: "idle", adapterType: "claude_local", icon: "book-open", urlKey: "wiki-maintainer" }, }; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Wiki Maintainer instructions differ from the plugin default: AGENTS.md."); expect(markup).toContain("Wiki Maintainer instruction defaults changed: AGENTS.md"); @@ -1344,9 +1344,7 @@ Duplicate headings receive stable suffixes. details: { cronExpression: "0 3 * * *" }, }]; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Routine setup needs repair"); expect(markup).toContain("Fix routines"); @@ -1358,9 +1356,7 @@ Duplicate headings receive stable suffixes. it("renders legacy lint routes inside the Wiki settings section", () => { mockPathname = "/PAP/wiki/lint"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("Run lint now"); expect(markup).toContain("Recent lint runs"); @@ -1369,9 +1365,7 @@ Duplicate headings receive stable suffixes. it("does not expose IDEA.md pattern editing as a settings section", () => { mockPathname = "/PAP/wiki/settings"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("LLM Wiki settings sections"); expect(markup).not.toContain(">Pattern"); @@ -1381,9 +1375,7 @@ Duplicate headings receive stable suffixes. it("does not expose plugin capabilities as a settings section", () => { mockPathname = "/PAP/wiki/settings"; - const markup = renderToStaticMarkup(createElement(WikiPage, { - context: { companyId: COMPANY_ID, companyPrefix: "PAP" }, - } as never)); + const markup = renderWikiPage(); expect(markup).toContain("LLM Wiki settings sections"); expect(markup).not.toContain("Plugin capabilities"); @@ -1392,11 +1384,7 @@ Duplicate headings receive stable suffixes. it("reconciles managed maintenance routines through stable agent and project refs", async () => { const harness = createTestHarness({ manifest }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); @@ -1651,11 +1639,7 @@ Duplicate headings receive stable suffixes. const harness = createTestHarness({ manifest }); const issue = paperclipIssue(); harness.seed({ issues: [issue] }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); await harness.emit("issue.created", { identifier: issue.identifier }, { @@ -1674,11 +1658,7 @@ Duplicate headings receive stable suffixes. const harness = createTestHarness({ manifest }); const issue = paperclipIssue(); harness.seed({ issues: [issue] }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); const policy = await harness.performAction<{ enabled: boolean; sources: { issues: boolean; comments: boolean; documents: boolean } }>( @@ -2768,11 +2748,7 @@ Duplicate headings receive stable suffixes. it("bootstraps required local wiki files through the local folder API", async () => { const harness = createTestHarness({ manifest }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); const result = await harness.performAction<{ writtenFiles: string[]; managedSkills: WikiSkillResource[] }>("bootstrap-root", { @@ -3009,11 +2985,7 @@ Duplicate headings receive stable suffixes. it("captures raw sources into local files and plugin metadata", async () => { const harness = createTestHarness({ manifest }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); const result = await harness.performAction<{ rawPath: string; hash: string }>("capture-source", { @@ -3031,11 +3003,7 @@ Duplicate headings receive stable suffixes. it("preserves manual source ingest for non-default spaces while refusing Paperclip distillation there", async () => { const harness = createTestHarness({ manifest }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); harness.seed({ agents: [wikiMaintainerAgent()], projects: [existingProject()], @@ -3057,26 +3025,7 @@ Duplicate headings receive stable suffixes. companyId: COMPANY_ID, displayName: "Team Research", }); - spaces.set(created.space.slug, { - id: created.space.id, - company_id: created.space.companyId, - wiki_id: created.space.wikiId, - slug: created.space.slug, - display_name: created.space.displayName, - space_type: "local_folder", - folder_mode: "managed_subfolder", - root_folder_key: "wiki-root", - path_prefix: created.space.pathPrefix, - configured_root_path: null, - access_scope: "shared", - owner_user_id: null, - owner_agent_id: null, - team_key: null, - settings: {}, - status: "active", - created_at: null, - updated_at: null, - }); + spaces.set(created.space.slug, managedSpaceRow(created)); const source = await harness.performAction<{ rawPath: string; spaceSlug: string }>("capture-source", { companyId: COMPANY_ID, @@ -3299,26 +3248,7 @@ Duplicate headings receive stable suffixes. companyId: COMPANY_ID, displayName: "Research Space", }); - spaces.set(created.space.slug, { - id: created.space.id, - company_id: created.space.companyId, - wiki_id: created.space.wikiId, - slug: created.space.slug, - display_name: created.space.displayName, - space_type: "local_folder", - folder_mode: "managed_subfolder", - root_folder_key: "wiki-root", - path_prefix: created.space.pathPrefix, - configured_root_path: null, - access_scope: "shared", - owner_user_id: null, - owner_agent_id: null, - team_key: null, - settings: {}, - status: "active", - created_at: null, - updated_at: null, - }); + spaces.set(created.space.slug, managedSpaceRow(created)); await harness.performAction("bootstrap-space", { companyId: COMPANY_ID, spaceSlug: created.space.slug, @@ -3364,6 +3294,8 @@ Duplicate headings receive stable suffixes. const pageWrites = harness.dbExecutes.filter((execute) => execute.sql.includes("wiki_pages")); const sourceWrites = harness.dbExecutes.filter((execute) => execute.sql.includes("wiki_sources")); + expect(pageWrites).toHaveLength(4); + expect(sourceWrites).toHaveLength(2); expect(pageWrites.every((write) => write.sql.includes("space_id"))).toBe(true); expect(sourceWrites.every((write) => write.sql.includes("space_id"))).toBe(true); }); @@ -3371,11 +3303,7 @@ Duplicate headings receive stable suffixes. it("ingests source metadata and creates a hidden plugin-operation issue", async () => { const harness = createTestHarness({ manifest }); harness.seed({ agents: [wikiMaintainerAgent()] }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); const result = await harness.performAction<{ @@ -3429,11 +3357,7 @@ Duplicate headings receive stable suffixes. it("rejects oversized source capture before raw writes or operation creation", async () => { const harness = createTestHarness({ manifest, config: { maxSourceBytes: 16 } }); harness.seed({ agents: [wikiMaintainerAgent()] }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); await expect(harness.performAction("ingest-source", { @@ -3568,26 +3492,7 @@ Duplicate headings receive stable suffixes. companyId: COMPANY_ID, displayName: "Research Space", }); - spaces.set(created.space.slug, { - id: created.space.id, - company_id: created.space.companyId, - wiki_id: created.space.wikiId, - slug: created.space.slug, - display_name: created.space.displayName, - space_type: "local_folder", - folder_mode: "managed_subfolder", - root_folder_key: "wiki-root", - path_prefix: created.space.pathPrefix, - configured_root_path: null, - access_scope: "shared", - owner_user_id: null, - owner_agent_id: null, - team_key: null, - settings: {}, - status: "active", - created_at: null, - updated_at: null, - }); + spaces.set(created.space.slug, managedSpaceRow(created)); const result = await harness.performAction<{ operationId: string; @@ -3669,6 +3574,13 @@ Duplicate headings receive stable suffixes. it("starts query sessions, records run ids, and forwards session events to plugin streams", async () => { const harness = createTestHarness({ manifest }); harness.seed({ agents: [wikiMaintainerAgent()] }); + let issuedRunId: string | undefined; + const originalSendMessage = harness.ctx.agents.sessions.sendMessage.bind(harness.ctx.agents.sessions); + harness.ctx.agents.sessions.sendMessage = async (...args) => { + const receipt = await originalSendMessage(...args); + issuedRunId = receipt.runId; + return receipt; + }; const streamEvents: unknown[] = []; harness.ctx.streams.emit = (_channel, event) => { streamEvents.push(event); @@ -3689,7 +3601,15 @@ Duplicate headings receive stable suffixes. expect(result.querySessionId).toBe(result.operationId); expect(result.channel).toBe(`llm-wiki:query:${result.operationId}`); expect(harness.dbExecutes.some((execute) => execute.sql.includes("wiki_query_sessions"))).toBe(true); - expect(harness.dbExecutes.some((execute) => execute.sql.includes("run_ids"))).toBe(true); + expect(issuedRunId).toEqual(expect.any(String)); + expect(issuedRunId).not.toBe(""); + expect(result.runId).toBe(issuedRunId); + const runUpdate = harness.dbExecutes.find((execute) => + /UPDATE .*wiki_operations/.test(execute.sql) && execute.sql.includes("run_ids"), + ); + expect(runUpdate?.params?.slice(0, 4)).toEqual([ + COMPANY_ID, result.operationId, "running", JSON.stringify([issuedRunId]), + ]); harness.simulateSessionEvent(result.sessionId, { runId: result.runId, @@ -3722,11 +3642,7 @@ Duplicate headings receive stable suffixes. it("files a streamed query answer as a page through a hidden file-as-page operation", async () => { const harness = createTestHarness({ manifest }); harness.seed({ agents: [wikiMaintainerAgent()] }); - const writes: Array<{ path: string; contents: string }> = []; - harness.ctx.localFolders.writeTextAtomic = async (_companyId, _folderKey, relativePath, contents) => { - writes.push({ path: relativePath, contents }); - return harness.ctx.localFolders.status(COMPANY_ID, "wiki-root"); - }; + const writes = captureWikiWrites(harness); await plugin.definition.setup(harness.ctx); const result = await harness.performAction<{ path: string; operationId: string; page: { revisionId: string } }>( diff --git a/packages/shared/src/agent-runner.ts b/packages/shared/src/agent-runner.ts index 08aefdedc22..59c36e1e987 100644 --- a/packages/shared/src/agent-runner.ts +++ b/packages/shared/src/agent-runner.ts @@ -7,7 +7,6 @@ export interface AgentRunnerAvailability { defaultRunner: AgentRunner; } -/** Only qualified harnesses graduate their creation default. */ export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | { provider: "acpx"; acpxAgent: "claude" } | undefined { if (harness === "codex_local") return { provider: "codex" }; if (harness === "claude_local") return { provider: "acpx", acpxAgent: "claude" }; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index ef162f11466..76c6349f794 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -763,11 +763,6 @@ describe("AppDefinition catalog", () => { APP_DEFINITIONS.find((app) => app.slug === "linear")?.methods[0]?.defaults ?.scopesHint, ).toEqual(["read", "write"])); - it("requests Hugging Face MCP read, contribution and job scopes", () => - expect( - APP_DEFINITIONS.find((app) => app.slug === "hugging-face")?.methods[0] - ?.defaults?.scopesHint, - ).toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"])); it("defaults every new connection action to allowed except Enterpret writes", () => { for (const app of APP_DEFINITIONS) for (const method of app.methods) { @@ -1327,21 +1322,6 @@ describe("AppDefinition catalog", () => { ?.available, ).toBe(false); }); - it("enforces method and field invariants", () => { - for (const app of APP_DEFINITIONS) - for (const method of app.methods) { - if ( - method.auth === "api_key" && - (method.purpose ?? "tool") !== "channel" - ) - expect(method.keyPlacement).toBeTruthy(); - if (method.auth === "oauth") - expect(method.ownershipModes.length).toBeGreaterThan(0); - for (const field of method.credentialFields ?? []) - if (field.required && field.type !== "checkbox") - expect(field.placeholder).toBeTruthy(); - } - }); }); diff --git a/packages/shared/src/slack-app-manifest.ts b/packages/shared/src/slack-app-manifest.ts index d8ec7d59ee0..e6d3f251424 100644 --- a/packages/shared/src/slack-app-manifest.ts +++ b/packages/shared/src/slack-app-manifest.ts @@ -22,7 +22,7 @@ export function buildSlackAppManifest(input: { redirectUri?: string; }) { return { - display_information: { name: input.app.appName }, + display_information: { name: input.app.appName, description: `Talk to ${input.agentName} and manage work in Paperclip.`.slice(0, 140) }, features: { app_home: { home_tab_enabled: false, messages_tab_enabled: true, messages_tab_read_only_enabled: false }, agent_view: { agent_description: "Work with a Paperclip agent in a task-backed conversation." }, @@ -60,6 +60,7 @@ export const slackRegistrationStateSchema = z.object({ status: slackRegistrationStatusSchema, appId: z.string().nullable().optional(), managementUrl: z.string().url().nullable().optional(), + managerGrantId: z.string().uuid().nullable().optional(), errorCode: z.string().nullable().optional(), }).strict(); export type SlackRegistrationState = z.infer; @@ -79,11 +80,33 @@ export const slackAccountStateSchema = z.object({ }).strict(); export type SlackAccountState = z.infer; export const slackInstallAuthorizationSchema = z.object({ authorizationUrl: z.string().url(), expiresAt: z.string().datetime() }); +export const slackManagerAuthorizationSchema = slackInstallAuthorizationSchema.extend({ handoff: z.object({ kind: z.literal("paperclip_cloud"), session: z.string().min(16).max(512) }).optional() }); +export type SlackManagerAuthorization = z.infer; export type SlackInstallAuthorization = z.infer; export const slackSetupActionSchema = z.object({}).strict(); +export const slackTestMessageSchema = z.object({ + requestId: z.string().uuid(), + slackUserId: z.string().regex(/^[UW][A-Z0-9]+$/).optional(), + accountCursor: z.string().min(1).max(512).optional(), +}).strict(); +export type SlackTestMessageInput = z.infer; +export const slackTestMessageResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("sent"), slackUrl: z.string().url() }).strict(), + z.object({ status: z.literal("connection_required"), accounts: z.array(z.object({ + id: z.string(), name: z.string(), + }).strict()), nextCursor: z.string().nullable() }).strict(), + z.object({ status: z.literal("connection_pending"), principalId: z.string().uuid(), expiresAt: z.string().datetime() }).strict(), +]); +export type SlackTestMessageResult = z.infer; + export function slackRegistrationErrorMessage(code: string): string { const messages: Record = { + slack_managed_unavailable: "Managed Slack setup is unavailable. Try again later or use your own app before creation starts.", + slack_manager_reauthorize: "Reconnect your Slack workspace to continue setting up this agent.", + slack_approval_pending: "Slack is waiting for your workspace administrator to approve this app.", + slack_approval_required: "Your Slack workspace requires approval. Continue in Slack to request it.", + slack_approval_denied: "Your Slack administrator declined this installation. Contact them to continue.", slack_configuration_token_invalid: "Slack rejected the app configuration access token. Generate a new access token and try again.", slack_manifest_invalid: "Slack rejected this app configuration. Check the app details or use manual setup.", slack_manifest_update_pending: "Your Slack app is saved, but its event settings still need to be applied. Enter an app configuration access token to retry configuring the same app.", @@ -96,9 +119,29 @@ export function slackRegistrationErrorMessage(code: string): string { slack_install_identity_mismatch: "Slack returned a different app, workspace, or bot. Install the app created for this connection.", slack_install_scopes_missing: "Slack did not grant all required bot permissions. Install the app again and approve its requested permissions.", slack_bot_already_connected: "This Slack bot is already connected to Paperclip. Resume its existing connection or use a different app.", + slack_install_token_invalid: "Slack rejected the saved bot credentials. Continue in Slack to authorize this app again.", slack_configuration_incomplete: "The app is installed, but Paperclip could not finish connecting it. Retry connecting the saved installation.", slack_install_account_missing: "Slack did not identify your account. Authorize the same app again to connect your Slack account.", slack_install_account_conflict: "This Slack account already has an existing link. Manage account links in connection settings, or authorize with your own Slack account.", }; return messages[code] ?? "Slack setup could not be completed. Resume setup and try again."; } + +/** Persisted legacy values keep their original meaning. Missing means manual. */ +export type SlackSetupMethod = "managed" | "automatic" | "manual" | "existing"; +export function isProvisionedSlackSetup(method: string | undefined): boolean { + return method === "automatic" || method === "managed"; +} +export const SLACK_MANAGER_SCOPES = ["app_configurations:read", "app_configurations:write", "managed_apps:install"] as const; +export const slackSetupOptionsSchema = z.object({ + managedAvailable: z.boolean(), + defaultMethod: z.enum(["managed", "automatic"]), + workspaces: z.array(z.object({ grantId: z.string().uuid(), workspaceId: z.string(), workspaceName: z.string(), userId: z.string() })), +}); +export type SlackSetupOptions = z.infer; +export const slackManagedProvisionSchema = z.object({ + requestId: z.string().uuid(), grantId: z.string().uuid(), confirmedNoAppCreated: z.boolean().optional(), reconnect: z.boolean().optional(), +}).strict(); +export type SlackManagedProvisionInput = z.infer; +export const slackManagedResultSchema = z.object({ authorization: slackInstallAuthorizationSchema.optional() }); +export type SlackManagedResult = z.infer; diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index af49abfb8da..5ebcda06a2b 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -190,7 +190,8 @@ export interface SlackAppConfiguration { } export interface ChatEndpointSetupState { - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; + slackManagerError?: string; slackRegistration?: import("../slack-app-manifest.js").SlackRegistrationState; /** Provider-confirmed app icon outcome; configuration tokens are never retained. */ slackAvatar?: import("../slack-app-manifest.js").SlackAvatarState; @@ -508,10 +509,11 @@ export interface CreateChatEndpointInput { applicationId?: string; name?: string; slackApp?: SlackAppConfiguration; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; } export interface UpdateChatEndpointInput { - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; slackApp?: SlackAppConfiguration; communicationInstructions?: string; allowDirectMessages?: boolean; diff --git a/packages/shared/src/validators/chat-channels.ts b/packages/shared/src/validators/chat-channels.ts index f2553588ef3..53b263462f0 100644 --- a/packages/shared/src/validators/chat-channels.ts +++ b/packages/shared/src/validators/chat-channels.ts @@ -80,9 +80,10 @@ export const createChatEndpointSchema = z applicationId: z.string().uuid().optional(), name: z.string().trim().min(1).max(160).optional(), slackApp: slackAppConfigurationSchema.optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), }) .strict() - .refine(value => !value.slackApp || value.provider === "slack", { + .refine(value => (!value.slackApp && !value.slackSetupMethod) || value.provider === "slack", { message: "Slack app details only apply to Slack connections", path: ["slackApp"], }); @@ -90,7 +91,7 @@ export const updateChatEndpointSchema = z .object({ communicationInstructions: multilineTextSchema.pipe(z.string().trim().max(4000)).optional(), slackApp: slackAppConfigurationSchema.optional(), - slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), allowDirectMessages: z.boolean().optional(), requireAtMention: z.boolean().optional(), allowGroupChats: z.boolean().optional(), diff --git a/packages/shared/src/worktree-seed-source.test.ts b/packages/shared/src/worktree-seed-source.test.ts index fa7688a6dde..852856b1b5b 100644 --- a/packages/shared/src/worktree-seed-source.test.ts +++ b/packages/shared/src/worktree-seed-source.test.ts @@ -7,7 +7,7 @@ import { resolveCanonicalWorktreeSeedSource } from "./worktree-seed-source.js"; const cleanup: string[] = []; function makeInstance(prefix: string, instanceId: string) { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const cwd = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), prefix)); cleanup.push(cwd); const configDir = path.join(cwd, ".paperclip"); const configPath = path.join(configDir, "config.json"); @@ -22,7 +22,7 @@ function makeInstance(prefix: string, instanceId: string) { * names its instance by directory and has no adjacent .env. */ function makeInstanceRoot(instanceId: string) { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-seed-home-")); + const home = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-seed-home-")); cleanup.push(home); const configDir = path.join(home, "instances", instanceId); fs.mkdirSync(configDir, { recursive: true }); @@ -33,7 +33,7 @@ function makeInstanceRoot(instanceId: string) { /** A managed project checkout: a plain clone with no `.paperclip` of its own. */ function makePlainCheckout() { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-seed-checkout-")); + const cwd = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "paperclip-seed-checkout-")); cleanup.push(cwd); return cwd; } diff --git a/releases/beta/v2026.1006.0-beta.0.md b/releases/v2026.1012.0.md similarity index 100% rename from releases/beta/v2026.1006.0-beta.0.md rename to releases/v2026.1012.0.md diff --git a/scripts/__tests__/e2e-shard.test.mjs b/scripts/__tests__/e2e-shard.test.mjs index ddeca9c1e3d..28bf3926f3c 100644 --- a/scripts/__tests__/e2e-shard.test.mjs +++ b/scripts/__tests__/e2e-shard.test.mjs @@ -16,6 +16,7 @@ const playwrightConfig = path.join(repoRoot, "tests", "e2e", "playwright.config. const prCallerWorkflow = path.join(repoRoot, ".github", "workflows", "pr.yml"); const trustedPrWorkflowPath = ".github/workflows/pr-trusted.yml"; const trustedPrWorkflow = path.join(repoRoot, trustedPrWorkflowPath); +const routingWorkflow = path.join(repoRoot, ".github", "workflows", "ci-routing.yml"); const SHARD_COUNT = 8; @@ -54,11 +55,11 @@ function readWorkflowJobs(workflow) { } function runStackScope(stack, prBaseRef) { - const workflow = readFileSync(trustedPrWorkflow, "utf8"); + const workflow = readFileSync(routingWorkflow, "utf8"); const match = workflow.match( - / - name: Select stacked PR CI scope[\s\S]*? run: \|\n([\s\S]*?)\n\n policy:/, + / - name: Select stacked PR CI scope[\s\S]*? run: \|\n([\s\S]*?)\s*$/, ); - assert.ok(match, "trusted workflow must define the stacked PR scope script"); + assert.ok(match, "routing workflow must define the stacked PR scope script"); const script = match[1] .split("\n") .map((line) => line.replace(/^ {10}/, "")) @@ -207,10 +208,22 @@ test("the trusted PR workflow keeps a stable aggregate check named e2e over the } }); +test("runner selection has reserved AWS capacity and executes metadata checks only", () => { + const workflow = readFileSync(routingWorkflow, "utf8"); + const jobs = readWorkflowJobs(workflow.split(/^jobs:\n/m)[1]); + assert.deepEqual([...jobs.keys()], ["gate"]); + assert.match(jobs.get("gate"), /^ {4}runs-on: \$\{\{ vars\.AWS_CI_ENABLED[\s\S]*paperclip-ci-routing-x64/m); + assert.doesNotMatch(jobs.get("gate"), /uses:|actions\/checkout|secrets\./); + assert.match(workflow, /^ {6}runner:\n {8}value: \$\{\{ jobs\.gate\.outputs\.runner \}\}$/m); + assert.match(workflow, /^ {6}full_ci:\n {8}value: \$\{\{ jobs\.gate\.outputs\.full_ci \}\}$/m); + assert.doesNotMatch(workflow, /^ {2}(?:actions|contents|pull-requests): write$/m); +}); + test("the trusted PR workflow limits full CI to merge-relevant stack layers", () => { const workflow = readFileSync(trustedPrWorkflow, "utf8"); const jobs = readWorkflowJobs(workflow); - const gate = jobs.get("gate"); + assert.match(jobs.get("gate"), /uses: paperclipai\/paperclip\/\.github\/workflows\/ci-routing\.yml@master/); + const gate = readWorkflowJobs(readFileSync(routingWorkflow, "utf8")).get("gate"); assert.match(gate, /^ {6}full_ci: \$\{\{ steps\.scope\.outputs\.full_ci \}\}$/m); assert.match(gate, /STACK_JSON: \$\{\{ toJSON\(github\.event\.pull_request\.stack\) \}\}/); diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 9fcf93ecbc1..944a8da594e 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -150,7 +150,6 @@ test('verification never elevates PR-controlled provisioning or cleanup on the h test('the image and Grok downloads start before staging and join without weakening the npm checks', () => { const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); - // The optional Mac consumer owns separate scratch and no Linux prefetches. const linuxSource = source.slice(source.indexOf("assert.equal(process.platform, 'linux'")); const position = fragment => { const index = linuxSource.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; }; // Exactly the pinned consumer image is pulled, for the platform the probes run on. @@ -349,8 +348,6 @@ test('the installed Codex probe exercises the public export and rejects incomple writeFileSync(legacyCommand, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(legacyCommand)};`); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /Selected Codex platform version must match the qualified pin/); - // Native Codex uses protocol compatibility, not the ACPX version allowlist. - // Keep exact official-npm host provenance and the selected resource boundary. mkdirSync(join(legacy, 'vendor')); const legacyNative = join(legacy, 'vendor', triple, 'codex', process.platform === 'win32' ? 'codex.exe' : 'codex'); mkdirSync(join(legacy, 'vendor', triple, 'codex'), { recursive: true }); writeFileSync(legacyNative, nativeBytes, { mode: 0o755 }); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index de216bcb5b4..6c65003cc2a 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -845,9 +845,6 @@ const waitForFixture = async (predicate, label) => { assert.fail(label); }; -// Execute the workflow's actual coordinator with two tiny local Node commands. -// Both must cross the start barrier before either can finish; no pnpm, Cargo, -// provider, registry, or Docker build runs in these fixtures. async function withNativePreparationFixture(mode, check) { const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'native-preparation-'))); let controller; @@ -1014,7 +1011,6 @@ test("ARM qualification binds separate product and reusable-workflow revisions e TOOLING_WORKFLOW_REF: "paperclipai/paperclip/.github/workflows/release-smoke.yml@refs/heads/qualification", TOOLING_WORKFLOW_PATH: ".github/workflows/release-smoke.yml", FIXTURE_PRODUCT_HEAD: productHead, FIXTURE_TOOLING_HEAD: toolingHead, FIXTURE_PREPARED: prepared, - // Reusable calls retain the caller context; it must not select tooling. GITHUB_REPOSITORY: "different/caller", GITHUB_SHA: "d".repeat(40), } }); assert.equal(result.status, expectedStatus, result.stderr); diff --git a/scripts/dev-runner-native-binary.mjs b/scripts/dev-runner-native-binary.mjs index d49eb01aaed..1df5eab04b3 100644 --- a/scripts/dev-runner-native-binary.mjs +++ b/scripts/dev-runner-native-binary.mjs @@ -50,9 +50,6 @@ export function resolveNativeRunnerDevBinary({ const flat = path.join(packageRoot, "dist", "bin", executable); const targetStat = statSync(target, { throwIfNoEntry: false }); const flatStat = statSync(flat, { throwIfNoEntry: false }); - // Release targets remain authoritative unless a newer source build matches - // this host. In particular, a build-host flat artifact cannot mask a remotely - // staged Mac target or force a local Cargo build. if (flatStat?.isFile() && (!targetStat?.isFile() || flatStat.mtimeMs > targetStat.mtimeMs) && runnerBinaryTarget(readFileSync(flat)) === `${platform}-${architecture}`) { return { binary: flat, sourceBuild: true }; diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 748df95ab72..407a5a34470 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -172,8 +172,6 @@ const env: NodeJS.ProcessEnv = { ...process.env, PAPERCLIP_UI_DEV_MIDDLEWARE: explicitUiDevMiddleware ?? (serveBuiltUiForManagedRuntime ? "false" : "true"), }; -// An automatic source-build override must not hide subsequent Rust edits from -// the watcher. Keep the operator's original choice separate from server env. const configuredRunnerBinary = env.PAPERCLIP_RUNNER_BINARY; if (mode === "dev") { diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 003a6e666ba..2db1a652665 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -11,8 +11,6 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [ 'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', ]; -// setup-node provides its matching SDK alongside the executing Node binary. -// Deferred lifecycle scripts may read that SDK, but may never download headers. export function macNodeHeadersDirectory(nodeExecutable, nodeVersion) { assert.match(nodeVersion, /^\d+\.\d+\.\d+$/, 'Executing Node must have an exact release version'); const executable = realpathSync(nodeExecutable); @@ -213,7 +211,6 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi `; } -/** Installed bytes and consumer resolution must survive host protocol startup. */ export function assertInstalledNativeConsumerContinuity(before, after) { for (const field of ['lockSha256', 'runnerSha256', 'codexCommandSha256', 'codexNativeExecutableSha256']) { if (!/^[a-f0-9]{64}$/.test(before[field]) || before[field] !== after[field]) { @@ -222,7 +219,6 @@ export function assertInstalledNativeConsumerContinuity(before, after) { } } -/** The installed consumer must select its packaged host daemon, never PATH. */ export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevision) { return ` import assert from 'node:assert/strict'; @@ -267,8 +263,6 @@ export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevis `; } -// Hosted, credential-free Mac consumers defer only npm lifecycle execution. -// Offline npm flags are not an OS network boundary; sandbox-exec supplies it. export function macPublicInstallLifecycleProfile(ownedRoot) { if (!ownedRoot.startsWith('/') || ownedRoot.includes('\0')) throw new Error('Mac lifecycle requires an absolute owned root'); return `(version 1)\n(allow default)\n(deny network*)\n(deny file-write* (require-all (require-not (subpath ${JSON.stringify(ownedRoot)})) (require-not (literal "/dev/null"))))\n`; diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs index 28b76362efc..3f95d570e69 100644 --- a/scripts/pi-public-install-probe.mjs +++ b/scripts/pi-public-install-probe.mjs @@ -32,8 +32,6 @@ export async function verifyPiPackagedDaemon(server, daemon, target, runnerBinar assert.equal(`sha256:${createHash('sha256').update(bytes).digest('hex')}`, artifact.sha256); assert.equal(runnerBinaryTarget(bytes), target); } else { - // Existing host-only source packages have no multi-target release manifest. - // They must still use their exact normal flat daemon, never an override. assert.equal(await realpath(daemon), expectedPath); assert.equal(runnerBinaryTarget(await readFile(expectedPath)), target); } diff --git a/scripts/prepare-linux-arm64-qualification.sh b/scripts/prepare-linux-arm64-qualification.sh index 162e17db4e4..f1c29f501f6 100755 --- a/scripts/prepare-linux-arm64-qualification.sh +++ b/scripts/prepare-linux-arm64-qualification.sh @@ -1,6 +1,4 @@ #!/usr/bin/env bash -# Prepare the disposable GitHub ARM qualification host using stock distro files. -# Extraction skips package maintainer scripts and their global policy changes. set -euo pipefail if [[ "${GITHUB_ACTIONS:-}" != true || "${RUNNER_OS:-}" != Linux ]]; then @@ -87,7 +85,6 @@ test "$(od -An -tx1 -N4 "$stock_binary" | tr -d ' \n')" = 7f454c46 readelf -h "$stock_binary" | tee "$prerequisite_report_dir/bwrap-elf-header.txt" grep -E 'Machine:.*AArch64' "$prerequisite_report_dir/bwrap-elf-header.txt" -# Reject every target symlink, including dangling links, before privileged copy. test ! -L /usr/bin/bwrap if test -e /usr/bin/bwrap; then test -f /usr/bin/bwrap diff --git a/scripts/release.sh b/scripts/release.sh index d28832d9046..6b154634fd6 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -284,9 +284,6 @@ release_info "" release_info "==> Step 2/7: Building workspace artifacts..." cd "$REPO_ROOT" pnpm build -# Hosted producers supply data-only, hash/source/architecture-bound daemon -# receipts. Assemble after the build (which stages its host binary), before -# public packages are packed. The server vendors this same compiled runner. if [ -n "${PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR:-}" ]; then pack_args=() if [ -n "${PAPERCLIP_RELEASE_PROVIDER_PACK_ARTIFACTS_DIR:-}" ]; then diff --git a/scripts/storybook-catalog.mjs b/scripts/storybook-catalog.mjs new file mode 100644 index 00000000000..41d777b4dd9 --- /dev/null +++ b/scripts/storybook-catalog.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const defaultRoot = path.resolve(path.dirname(scriptPath), ".."); +const storyRoot = "ui/storybook/stories"; +const catalogPath = "maps/storybook-map/catalog.json"; +const indexPath = "maps/storybook-map/INDEX.md"; +const statuses = ["current", "proposed", "historical", "mixed", "unreviewed"]; +const implementations = ["production", "prototype", "mixed", "guide"]; +const boundaries = ["fixtures", "fixture-server", "static"]; + +function files(root, dir) { + return readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap(entry => { + const name = `${dir}/${entry.name}`; + return entry.isDirectory() ? files(root, name) : [name]; + }).sort(); +} + +export function fingerprint(root, sources) { + const hash = createHash("sha256"); + for (const file of [...new Set(sources)].sort()) { + hash.update(file).update("\0").update(readFileSync(path.join(root, file))).update("\0"); + } + return hash.digest("hex"); +} + +export function capabilities(root) { + const result = new Map(); + for (const file of files(root, "maps/feature-map").filter(file => file.endsWith(".md") && !file.endsWith("README.md"))) { + const text = readFileSync(path.join(root, file), "utf8"); + const area = path.basename(file, ".md"); + const section = text.split(/^## Sub-features\s*$/m)[1]?.split(/^## /m)[0] ?? ""; + for (const match of section.matchAll(/^- `([^`]+)`:/gm)) result.set(`${area}/${match[1]}`, file); + } + return result; +} + +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; +} + +export function scanStories(root = defaultRoot) { + const require = createRequire(path.join(root, "ui/package.json")); + const { loadCsf } = require("storybook/internal/csf-tools"); + return files(root, storyRoot).filter(file => /\.stories\.(ts|tsx|mdx)$/.test(file)).map(file => { + const csf = loadCsf(readFileSync(path.join(root, file), "utf8"), { fileName: file, makeTitle: title => title }).parse(); + return { file, title: csf.meta.title, metaId: csf.meta.id, ids: csf.stories.map(story => story.id), plays: csf.stories.filter(story => story.__stats?.play).length }; + }); +} + +export function validateCatalog(root, catalog, stories) { + const errors = []; + const refs = capabilities(root); + const navigation = featureNavigation(root); + const byFile = new Map(stories.map(story => [story.file, story])); + const seen = new Set(); + const local = file => typeof file === "string" && !path.isAbsolute(file) && !file.split("/").includes("..") && existsSync(path.join(root, file)); + if (catalog.version !== 1 || !Array.isArray(catalog.entries)) return ["Unsupported catalog version or missing entries"]; + function review(label, sources, hash) { + if (!Array.isArray(sources) || !sources.length || sources.some(file => !local(file))) { + errors.push(`${label}: missing/invalid review sources`); + } else if (fingerprint(root, sources) !== hash) errors.push(`${label}: source changed; review before updating its fingerprint`); + } + review("Shared Storybook setup", catalog.sharedReview?.sources, catalog.sharedReview?.fingerprint); + for (const entry of catalog.entries) { + const label = entry.file; + if (seen.has(label)) errors.push(`${label}: duplicate catalog entry`); + seen.add(label); + if (!byFile.has(label)) errors.push(`${label}: missing story file`); + if (!statuses.includes(entry.status)) errors.push(`${label}: invalid product status`); + if (!implementations.includes(entry.implementation)) errors.push(`${label}: invalid implementation`); + if (!boundaries.includes(entry.boundary)) errors.push(`${label}: invalid data boundary`); + 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; } + 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 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}`); + if (!prefix || !entry.sidebarTitle?.startsWith(`${prefix}/`) || story?.title !== entry.sidebarTitle) errors.push(`${label}: sidebar title must match its feature-map home`); + if (!entry.stableId || story?.metaId !== entry.stableId) errors.push(`${label}: explicit meta ID must preserve the catalog stableId`); + if (entry.replacement && !byFile.has(entry.replacement)) errors.push(`${label}: replacement story is missing`); + if (!entry.sources?.includes(label)) errors.push(`${label}: review must include its story source`); + 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); + 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) { + 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"; + if (!groups.has(area)) groups.set(area, []); + groups.get(area).push(entry); + } + const lines = ["# Storybook by feature", "", "", "", + "[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)`, ""] : []), + "| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note |", + "| --- | --- | --- | --- | --- |"); + for (const entry of entries.sort((a, b) => a.file.localeCompare(b.file))) { + const story = byFile.get(entry.file); + lines.push(`| [${cell(story.title)}](../../${entry.file}) | ${entry.capabilities.map(ref => `\`${ref}\``).join(", ") || cell(entry.exception)} | ${entry.status} / ${entry.implementation} / ${entry.boundary} | ${story.ids.length} / ${story.plays} | ${cell(entry.notes)} |`); + } + 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()) { + 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"; +} + +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"); + } + 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 new file mode 100644 index 00000000000..8a2d5f2a086 --- /dev/null +++ b/scripts/storybook-catalog.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs"; +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"; + +function fixture(t) { + const root = mkdtempSync(path.join(os.tmpdir(), "storybook-catalog-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + function write(file, text) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), text); + } + write("maps/feature-map/README.md", "# Features\n\n### Tasks and conversations\n\n| [Tasks](./tasks.md) | Tasks |\n"); + write("maps/feature-map/tasks.md", "# Tasks\n\n## Sub-features\n\n- `create`: create\n- `discussion`: discuss\n\n## How to get to it\n"); + write("ui/storybook/.storybook/preview.tsx", "// shared fixtures\n"); + const file = "ui/storybook/stories/tasks/task.stories.tsx"; + write(file, "// a story\n"); + write("ui/src/Task.tsx", "// production subject\n"); + const sharedSources = ["ui/storybook/.storybook/preview.tsx"]; + const sources = [file, "ui/src/Task.tsx"]; + const catalog = { + version: 1, sharedReview: { sources: sharedSources, fingerprint: fingerprint(root, sharedSources) }, + entries: [{ file, stableId: "tasks-create", sidebarTitle: "Tasks and conversations/Tasks/Create", capabilities: ["tasks/create", "tasks/discussion"], purpose: "Create a task", status: "current", + implementation: "production", boundary: "fixtures", notes: "Production subject; callbacks simulated.", + reviewedAt: "2026-10-12", sources, fingerprint: fingerprint(root, sources) }], + }; + const stories = [{ file, title: "Tasks and conversations/Tasks/Create", metaId: "tasks-create", ids: ["tasks-create--empty"], plays: 0 }]; + return { root, write, file, catalog, stories }; +} + +test("complete inventory accepts production subjects with simulated data", t => { + const f = fixture(t); + assert.deepEqual(validateCatalog(f.root, f.catalog, f.stories), []); +}); + +test("adding, removing or duplicating story files requires reconciliation", t => { + const f = fixture(t); + assert.match(validateCatalog(f.root, { ...f.catalog, entries: [] }, f.stories).join("\n"), /missing from catalog/); + assert.match(validateCatalog(f.root, f.catalog, []).join("\n"), /missing story file/); + f.catalog.entries.push(f.catalog.entries[0]); + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /duplicate catalog entry/); +}); + +test("capability renames and unjustified unmapped stories fail", t => { + const f = fixture(t); + f.catalog.entries[0].capabilities = ["tasks/old-name"]; + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /unknown feature capability/); + f.catalog.entries[0].capabilities = []; + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /explicit reason/); + f.catalog.entries[0].exception = "Shared foundation"; + const file = "ui/storybook/stories/foundations/task.stories.tsx"; + f.write(file, "export default {};"); + const entry = f.catalog.entries[0]; + entry.file = file; + entry.sources = [file, "ui/src/Task.tsx"]; + entry.fingerprint = fingerprint(f.root, entry.sources); + entry.sidebarTitle = "Foundations/Task"; + f.stories[0].file = file; + f.stories[0].title = entry.sidebarTitle; + assert.deepEqual(validateCatalog(f.root, f.catalog, f.stories), []); +}); + +test("feature organization rejects wrong folders, sidebar homes and unstable meta IDs", t => { + const f = fixture(t); + f.catalog.entries[0].capabilities = ["tasks/discussion"]; + assert.deepEqual(validateCatalog(f.root, f.catalog, f.stories), []); + f.stories[0].metaId = "renamed-task"; + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /explicit meta ID/); + f.stories[0].metaId = "tasks-create"; + f.stories[0].title = "Unrelated/Task"; + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /sidebar title/); + f.stories[0].title = f.catalog.entries[0].sidebarTitle; + const originalFile = f.catalog.entries[0].file; + f.catalog.entries[0].file = "ui/storybook/stories/foundations/task.stories.tsx"; + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /primary feature folder tasks/); + f.catalog.entries[0].file = originalFile; + f.write("maps/feature-map/README.md", "### Conversations\n| [Tasks](./tasks.md) | Tasks |\n"); + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /sidebar title/); +}); + +test("production and shared fixture drift invalidate review fingerprints", t => { + const f = fixture(t); + f.write("ui/src/Task.tsx", "// changed contract\n"); + f.write("ui/storybook/.storybook/preview.tsx", "// changed shared mocks\n"); + const errors = validateCatalog(f.root, f.catalog, f.stories); + assert.equal(errors.filter(error => error.includes("source changed")).length, 2); + assert.equal(f.catalog.entries[0].fingerprint.length, 64); +}); + +test("missing and escaping source references cannot certify a review", t => { + const f = fixture(t); + f.catalog.entries[0].sources.push("../outside.ts"); + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /invalid review sources/); +}); + +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)); + assert.match(index, /tasks\/create.*\(historical\)/); + assert.match(index, /tasks\/discussion.*\(historical\)/); + assert.match(index, /1 story files; 1 indexed stories/); + assert.match(index, /historical \/ production \/ fixtures/); +}); + +test("duplicate story IDs cannot silently collapse distinct examples", t => { + const f = fixture(t); + f.stories[0].ids.push(f.stories[0].ids[0]); + assert.match(validateCatalog(f.root, f.catalog, f.stories).join("\n"), /Duplicate story IDs/); +}); + +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"}'); + symlinkSync(fileURLToPath(new URL("../ui/node_modules", import.meta.url)), path.join(f.root, "ui/node_modules"), "junction"); + f.write(f.file, ` + import type { Meta, StoryObj } from "@storybook/react-vite"; + const meta = { title: "Tasks/Create", id: "stable-task", excludeStories: ["empty"] } satisfies Meta; + export default meta; + type Story = StoryObj; + export const empty = { value: "fixture" }; + export const Empty: Story = {}; + export const Filled: Story = { play: async () => {} }; + `); + f.write("ui/storybook/stories/nested/other.stories.ts", ` + export default { title: "Other/Subject", includeStories: ["Included"] }; + export const helper = {}; + export const Included = {}; + `); + f.write("ui/storybook/stories/nested/fixture.ts", "export const fixture = {};"); + const stories = scanStories(f.root); + assert.equal(stories.length, 2); + assert.deepEqual(stories.find(story => story.file === f.file), { + file: f.file, title: "Tasks/Create", metaId: "stable-task", ids: ["stable-task--empty", "stable-task--filled"], plays: 1, + }); + assert.deepEqual(stories.find(story => story.file.endsWith("other.stories.ts")), { + file: "ui/storybook/stories/nested/other.stories.ts", title: "Other/Subject", metaId: undefined, ids: ["other-subject--included"], plays: 0, + }); +}); diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index e06edb898ba..7d9133b0bea 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -36,8 +36,6 @@ async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) for (const directory of [consumer, home, tools, join(root, 'tmp'), join(root, 'cache')]) mkdirSync(directory); const npmCli = realpathSync(execFileSync('/usr/bin/which', ['npm'], { encoding: 'utf8' }).trim()); symlinkSync(process.execPath, join(tools, 'node')); - // npm subprocesses get a Node-only owned tool directory; no ambient harness - // command can hide missing installed dependencies. const env = { PATH: `${tools}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: join(root, 'tmp'), npm_config_cache: join(root, 'cache'), npm_config_audit: 'false', npm_config_fund: 'false' }; const run = (command, args, cwd = consumer, runEnv = env) => execFileSync(command, args, { cwd, env: runEnv, @@ -148,13 +146,8 @@ async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptP const daemon = probe('daemon-probe.mjs', installedDaemonProbeSource(installed, '/consumer', sourceRevision)); assert.equal(daemon.target, 'linux-arm64'); const codex = probe('codex-probe.mjs', installedCodexProbeSource(installed + '/index.js', '/consumer', undefined, { nativeCodex: true })); - // Reuse the existing zero-turn hook as read-only verifier source. All - // production imports still resolve from the installed consumer graph. cpSync(join(repo, 'tests/runner-e2e/installed-cli.ts'), join(assets, 'installed-cli.ts')); writeFileSync(join(assets, 'types.js'), stripTypeScriptTypes(readFileSync(join(repo, 'tests/runner-e2e/types.ts'), 'utf8'))); - // The restricted install container deliberately forbids nested namespaces. - // Keep its lifecycle/no-network proof, then test the exact same bind-mounted - // graph on the native host using Codex's normal read-only namespace policy. const home = join(root, 'host-home'), hostTmp = join(root, 'host-tmp'); for (const directory of [home, hostTmp]) mkdirSync(directory, { mode: 0o700 }); const hostEnv = { PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' }; @@ -332,9 +325,6 @@ try { timing.stage_pack_ms = Math.round(performance.now() - packStarted); Object.assign(timing, packTimings(packs, concurrency)); - // Preserve the exact Linux-produced public tarballs for independent Mac - // consumers even if a later Linux runtime probe fails. This is a packaging - // receipt, never an installation or provider-admission pass. if (process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR) { const output = resolve(process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR); assert.equal(JSON.parse(readFileSync(join(repo, 'server/dist/build-info.json'))).commit, sourceRevision); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 8cf0ff8b23f..5d9b638026c 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -261,6 +261,17 @@ function makeAgent(adapterType: string) { }; } +function skillSnapshot(adapterType = "claude_local", mode = "ephemeral") { + return { + adapterType: adapterType, + supported: true, + mode: mode, + desiredSkills: ["paperclipai/paperclip/paperclip"], + entries: [], + warnings: [], + }; +} + describe("agent skill routes", () => { beforeEach(async () => { runnerTarget.platform = "linux"; @@ -320,22 +331,8 @@ describe("agent skill routes", () => { unresolved: [], }), ); - mockAdapter.listSkills.mockResolvedValue({ - adapterType: "claude_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); - mockAdapter.syncSkills.mockResolvedValue({ - adapterType: "claude_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); + mockAdapter.listSkills.mockResolvedValue(skillSnapshot()); + mockAdapter.syncSkills.mockResolvedValue(skillSnapshot()); mockAgentService.update.mockImplementation(async (_id: string, patch: Record) => { const previousAgent = persistedAgent ?? makeAgent("claude_local"); persistedAgent = { @@ -478,61 +475,11 @@ describe("agent skill routes", () => { ); }); - it("threads a non-undefined actor secret context into resolveAdapterConfigForRuntime on both skills routes (audit fidelity, skipUserSecrets preserved)", async () => { - const expectedContext = { - consumerType: "agent", - consumerId: "11111111-1111-4111-8111-111111111111", - actorType: "user", - actorId: "local-board", - actorSource: "local_implicit", - responsibleUserId: "local-board", - }; - // GET /agents/:id/skills - mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); - const listRes = await requestApp( - await createApp(), - (baseUrl) => request(baseUrl) - .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"), - ); - expect(listRes.status, JSON.stringify(listRes.body)).toBe(200); - const listCall = mockSecretService.resolveAdapterConfigForRuntime.mock.calls.at(-1); - expect(listCall?.[2]).toBeDefined(); - expect(listCall?.[2]).toEqual(expectedContext); - expect(listCall?.[3]).toEqual({ adapterType: "claude_local", skipUserSecrets: true }); - - // POST /agents/:id/skills/sync - mockAdapter.syncSkills.mockResolvedValue({ - adapterType: "claude_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); - const syncRes = await requestApp( - await createApp(), - (baseUrl) => request(baseUrl) - .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") - .send({ desiredSkills: ["paperclip"], mode: "replace" }), - ); - expect(syncRes.status, JSON.stringify(syncRes.body)).toBe(200); - const syncCall = mockSecretService.resolveAdapterConfigForRuntime.mock.calls.at(-1); - expect(syncCall?.[2]).toBeDefined(); - expect(syncCall?.[2]).toEqual(expectedContext); - expect(syncCall?.[3]).toEqual({ adapterType: "claude_local", skipUserSecrets: true }); - }); it("skips runtime materialization when listing Codex skills", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("codex_local")); - mockAdapter.listSkills.mockResolvedValue({ - adapterType: "codex_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); + mockAdapter.listSkills.mockResolvedValue(skillSnapshot("codex_local")); const res = await requestApp( await createApp(), @@ -555,14 +502,7 @@ describe("agent skill routes", () => { mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValueOnce({ config: { agent: "claude" }, }); - mockAdapter.listSkills.mockResolvedValue({ - adapterType: "acpx_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); + mockAdapter.listSkills.mockResolvedValue(skillSnapshot("acpx_local")); const res = await requestApp( await createApp(), @@ -603,14 +543,7 @@ describe("agent skill routes", () => { }, }, }); - mockAdapter.syncSkills.mockResolvedValue({ - adapterType: "acpx_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); + mockAdapter.syncSkills.mockResolvedValue(skillSnapshot("acpx_local")); const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") @@ -831,14 +764,7 @@ describe("agent skill routes", () => { it("skips runtime materialization when listing persistent skill adapters", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("cursor")); - mockAdapter.listSkills.mockResolvedValue({ - adapterType: "cursor", - supported: true, - mode: "persistent", - desiredSkills: ["paperclipai/paperclip/paperclip"], - entries: [], - warnings: [], - }); + mockAdapter.listSkills.mockResolvedValue(skillSnapshot("cursor", "persistent")); const res = await requestApp( await createApp(), diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 8b8b0f29641..6893a548784 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1,3 +1,7 @@ +import * as activityLogModule from "../services/activity-log.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"; import { registerChatActionWork, dueChatAction } from "../services/chat-action-work.js"; import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js"; import { registerChatDeliveryWork } from "../services/chat-delivery-work.js"; @@ -7,14 +11,11 @@ import { aiConnectionService } from "../services/ai-connections.js"; import { fastResponseService } from "../services/fast-responses.js"; import { fastResponseReceipt } from "../services/fast-response-provider.js"; import { fastResponseRequests } from "@paperclipai/db"; -import { chatSlackRegistrations, toolOauthStates } from "@paperclipai/db"; -import { buildSlackAppManifest } from "@paperclipai/shared"; import { toolAccessService } from "../services/tool-access.js"; import { toolActionRequests, toolInvocations } from "@paperclipai/db"; import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js"; import { secretService } from "../services/secrets.js"; import { githubChatWizardService } from "../services/chat-github-wizard.js"; -import type { PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; import { dispatchGitHubBotCloudEvent } from "../services/chat-github-cloud-ingress.js"; import { githubChatManagementService } from "../services/chat-github-management.js"; import { githubChatReviewService } from "../services/chat-github-reviews.js"; @@ -6262,7 +6263,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const token = `xoxb-bot-token-canary-${id}`; const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot; const state = { - failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false, + managedAccessToken: token, rejectOldBotToken: false, + userListCursor: null as string | null, userListNextCursor: "", + managedBotAppId: appId, grantedScopes: scopes, managedError: null as string | null, failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false, failManifestUpdate: false, timeoutManifestUpdate: false, beforeManifestUpdate: null as null | (() => Promise), failIcon: false, timeoutIcon: false, failUser: false, failWelcome: false, timeoutWelcome: false, @@ -6270,20 +6273,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { user: { id: installerId, team_id: "TAUTO", name: "installer", real_name: "Installing Person", is_bot: false, deleted: false }, beforeIcon: null as null | (() => Promise), beforeAuth: null as null | (() => Promise), + beforeExchange: null as null | (() => Promise), oauth: { ok: true, app_id: appId, token_type: "bot", access_token: token, team: { id: "TAUTO" }, bot_user_id: botId, authed_user: { id: installerId }, scope: scopes.join(",") }, }; const fallback = fakeSlackFetch(botId); const provider = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); + if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BMANAGED", app_id: state.managedBotAppId, user_id: botId, deleted: false } }); + if (url.endsWith("apps.managedInstall")) return Response.json(state.managedError ? { ok: false, error: state.managedError } : { ok: true, app_id: appId, team_id: "TAUTO", api_access_tokens: { bot_access_token: state.managedAccessToken } }); if (url.endsWith("apps.manifest.validate")) return Response.json(state.failValidate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true }); if (url.endsWith("apps.manifest.create")) { if (state.failCreate) throw new Error(`Timeout ${configToken} ${clientSecret}`); - return Response.json({ ok: true, app_id: appId, credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } }); + return Response.json({ ok: true, app_id: appId, team_id: "TAUTO", credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } }); } if (url.endsWith("apps.manifest.update")) { await state.beforeManifestUpdate?.(); if (state.timeoutManifestUpdate) throw new Error(`Manifest timeout ${configToken} ${clientSecret}`); - return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId }); + return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId, permissions_updated: true }); } if (url.endsWith("apps.icon.set")) { await state.beforeIcon?.(); @@ -6291,13 +6297,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { return Response.json(state.failIcon ? { ok: false, error: "missing_scope", detail: `${configToken} ${signingSecret}` } : { ok: true }); } if (url.endsWith("oauth.v2.access")) { + await state.beforeExchange?.(); if (state.failExchange) throw new Error(`Timeout ${clientSecret} ${token}`); return Response.json(state.oauth); } if (url.endsWith("auth.test")) { await state.beforeAuth?.(); + if (state.rejectOldBotToken && new Headers(init?.headers).get("authorization") !== `Bearer ${state.managedAccessToken}`) return Response.json({ ok: false, error: "token_revoked" }); if (state.failAuth) throw new Error(`Temporary failure ${token}`); - return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } }); + return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, bot_id: "BMANAGED", user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": state.grantedScopes.join(",") } }); + } + if (url.endsWith("users.list")) { + state.userListCursor = new URLSearchParams(String(init?.body)).get("cursor"); + return Response.json({ ok: true, members: [state.user], response_metadata: { next_cursor: state.userListNextCursor } }); } if (url.endsWith("users.info")) { await state.beforeUser?.(); @@ -6323,6 +6335,408 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const install = async () => new URL((await service.slackRegistration.install(endpoint.id, actor)).authorizationUrl).searchParams.get("state")!; return { ...company, endpoint, service, sdk, app, state, provider, create, install, createInput, token, appId, botId, installerId, avatarBytes, renderAvatar }; } + async function managedFixture() { + const broker = { + getCapabilities: vi.fn(async () => ["slack.manager"]), + getSlackManagerAppId: vi.fn(async () => "AMANAGER"), + startAuthorization: vi.fn(async (_input: { returnState: string }) => ({ authorizationUrl: "https://slack.com/oauth/v2/authorize?state=broker", expiresAt: new Date(Date.now() + 600_000).toISOString() })), + claim: vi.fn(), refresh: vi.fn(), revoke: vi.fn(async () => undefined), + }; + const f = await fixture({ slackManaged: { enabled: true, broker: broker as unknown as PaperclipCloudConnector } }); + const credentials: SealedConnectorCredentials = { v: 1, provider: "slack", profile: "slack.manager", instanceId: "test", environment: "staging", subject: actor.userId, companyId: f.companyId, + accessToken: "xoxe.xoxp-manager-access-canary", refreshToken: "xoxe-manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: new Date(Date.now() + 43_200_000).toISOString(), refreshTokenExpiresAt: null, + scopes: [...SLACK_MANAGER_SCOPES], slackManager: { appId: "AMANAGER", workspaceId: "TAUTO", workspaceName: "Test workspace", userId: f.installerId } }; + broker.claim.mockResolvedValue(credentials); + broker.refresh.mockResolvedValue({ ...credentials, accessToken: "xoxe.rotated-canary", refreshToken: "xoxe.rotated-refresh-canary" }); + await f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId); + const grants = f.service.slackRegistration.managerGrants; + const authorize = async () => { await grants.authorize(f.endpoint.id, actor); return broker.startAuthorization.mock.calls.at(-1)![0].returnState; }; + const authorizeState = await authorize(); + await grants.complete(authorizeState, "claim-canary", null, actor); + const choices = await grants.choices(f.companyId, actor); + expect(choices.workspaces).toHaveLength(1); + const grantId = choices.workspaces[0]!.grantId; + const input = { requestId: randomUUID(), grantId }; + const provision = () => f.service.slackRegistration.managed.provision(f.endpoint.id, actor, input); + return { ...f, broker, credentials, grants, authorize, authorizeState, grantId, input, provision }; + } + describe("Slack test messages", () => { + const posts = (f: Awaited>) => f.provider.mock.calls.filter(([url]) => String(url).endsWith("chat.postMessage")); + it("sends to the current linked user and reuses a durable result on duplicate requests", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + const input = { requestId: randomUUID() }; + const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(200); + expect(result.body).toEqual({ status: "sent", slackUrl: "https://app.slack.com/client/TAUTO/DWELCOME" }); + await f.service.sendSlackTestMessage(f.endpoint.id, actor, input); + expect(posts(f)).toHaveLength(1); + expect(new URLSearchParams(String(posts(f)[0]![1]!.body)).get("channel")).toBe(f.installerId); + }); + it("links the actor's own manager identity without requiring a connect command", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).toMatchObject({ status: "sent" }); + expect(await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id))).toEqual([expect.objectContaining({ paperclipUserId: actor.userId, status: "linked" })]); + }); + it("requires a private Slack challenge for an unknown account before linking and sending the test", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.provider.mockClear(); const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual({ status: "connection_required", accounts: [{ id: f.installerId, name: "Installing Person" }], nextCursor: null }); + expect(posts(f)).toHaveLength(0); + const pending = await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, slackUserId: f.installerId }); + expect(pending.status).toBe("connection_pending"); + const links = await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + expect(links).toEqual([expect.objectContaining({ status: "pending", paperclipUserId: null })]); + const fields = new URLSearchParams(String(posts(f)[0]![1]!.body)); + expect(fields.get("text")).not.toContain("This is your test message"); + const blocks = JSON.parse(fields.get("blocks")!); + const invitationUrl = /<([^|]+)\|/.exec(blocks[1].text.text)![1]!; + const token = new URL(invitationUrl).searchParams.get("token")!; + expect(JSON.stringify(pending)).not.toContain(token); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual(pending); + expect(posts(f)).toHaveLength(1); + await f.service.confirmIdentityLink(token, actor.userId); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(new URLSearchParams(String(posts(f).at(-1)![1]!.body)).get("text")).toContain("This is your test message"); + }); + it("loads another workspace page instead of truncating account discovery", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.provider.mockClear(); f.state.userListNextCursor = "next-page"; + const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "connection_required", nextCursor: "next-page" }); + f.state.userListNextCursor = ""; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, accountCursor: "next-page" })).toMatchObject({ status: "connection_required", nextCursor: null }); + expect(f.state.userListCursor).toBe("next-page"); + expect(posts(f)).toHaveLength(0); + }); + it("does not downgrade confirmed delivery when activity logging fails", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + const original = activityLogModule.logActivity; + const audit = vi.spyOn(activityLogModule, "logActivity").mockImplementation(async (database, entry) => { + if (entry.action === "chat_slack.test_message_sent") throw new Error("Synthetic activity failure"); + return original(database, entry); + }); + onTestFinished(() => audit.mockRestore()); + const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(posts(f)).toHaveLength(1); + }); + it("does not restore revoked access or take another user's identity", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("existing link"); + await db.update(chatIdentityLinks).set({ status: "linked", paperclipUserId: "another-user" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID(), slackUserId: f.installerId })).rejects.toThrow("existing link"); + expect(posts(f)).toHaveLength(0); + }); + it("rejects wrong-workspace users, disabled DMs, and membership revocation", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + f.state.user.team_id = "TOTHER"; + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("personal Slack account"); + f.state.user.team_id = "TAUTO"; + await db.update(chatEndpoints).set({ allowDirectMessages: false }).where(eq(chatEndpoints.id, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("Enable direct messages"); + await db.update(chatEndpoints).set({ allowDirectMessages: true }).where(eq(chatEndpoints.id, f.endpoint.id)); + await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, actor.userId))); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("active Paperclip account"); + expect(posts(f)).toHaveLength(0); + }); + it("redacts provider failures and never retries an ambiguous send", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); f.state.timeoutWelcome = true; + const input = { requestId: randomUUID() }; + const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(422); + expect(JSON.stringify(result.body)).not.toContain("canary"); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).rejects.toThrow("may already have been sent"); + expect(posts(f)).toHaveLength(1); + const actions = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "slack_test_message"))); + expect(JSON.stringify(actions)).not.toContain("canary"); + expect(actions[0]!.status).toBe("uncertain"); + }); + }); + describe("managed Slack", () => { + it("keeps open-source setup independent of the broker", async () => { + const f = await fixture(); + expect(await f.service.slackRegistration.managerGrants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "automatic", workspaces: [] }); + await f.create(); + expect((await f.service.get(f.endpoint.id)).setup.slackSetupMethod).toBe("automatic"); + await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId)).rejects.toThrow(); + }); + it("keeps configured Cloud drafts managed when the broker profile disappears", async () => { + const f = await managedFixture(); + f.broker.getSlackManagerAppId.mockResolvedValue(null); + expect(f.grants.defaultMethod()).toBe("managed"); + expect(await f.grants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "managed", workspaces: [] }); + const next = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId }, actor.userId); + expect(next.setup.slackSetupMethod).toBe("managed"); + await expect(f.provision()).rejects.toThrow(); + }); + it("creates and installs once, uploads the avatar and links only the manager grant owner", async () => { + const f = await managedFixture(); + await Promise.all([f.provision(), f.provision()]); + const saved = await f.service.get(f.endpoint.id); + expect(saved.setup).toMatchObject({ slackSetupMethod: "managed", slackRegistration: { status: "configured", appId: f.appId }, slackAvatar: { status: "uploaded" }, slackAccount: { status: "linked", paperclipUserId: actor.userId, externalUserId: f.installerId } }); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + const activities = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId)); + for (const secret of [f.credentials.accessToken, f.credentials.refreshToken!, f.token, clientSecret, signingSecret, "claim-canary"]) expect(JSON.stringify({ saved, activities, choices: await f.grants.choices(f.companyId, actor) })).not.toContain(secret); + await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "automatic" }, actor.userId)).rejects.toThrow(); + }); + it("reuses the same personal workspace grant for another draft without another authorization", async () => { + const f = await managedFixture(); + const draft = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed" }, actor.userId); + expect((await f.grants.choices(f.companyId, actor)).workspaces[0]!.grantId).toBe(f.grantId); + const otherActor = { ...actor, userId: "another-user", bypassPermissionCheck: true }; + expect((await f.grants.choices(f.companyId, otherActor)).workspaces).toEqual([]); + await expect(f.service.slackRegistration.managed.provision(draft.id, otherActor, { ...f.input, requestId: randomUUID() })).rejects.toThrow(); + expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1); + }); + it("installs a second bot using the same grant without new manager consent", async () => { + const f = await managedFixture(); await f.provision(); + const secondBot = `${f.botId}SECOND`; + const secondApp = `${f.appId}SECOND`; + const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot; + const provider: typeof fetch = async (input, init) => { + const url = String(input); + if (url.endsWith("apps.manifest.create")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", credentials: { client_id: "222.333", client_secret: clientSecret, signing_secret: signingSecret } }); + if (url.endsWith("apps.managedInstall")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", api_access_tokens: { bot_access_token: "xoxb-second-managed" } }); + if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: "TAUTO", user_id: secondBot, bot_id: "BSECOND", team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } }); + if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BSECOND", app_id: secondApp, user_id: secondBot, deleted: false } }); + return f.provider(input, init); + }; + const { service } = createService(new FakeChatSdkRuntime(), provider, { renderSlackAvatar: f.renderAvatar, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } }); + const second = await service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed", slackApp: appDetails }, actor.userId); + await service.slackRegistration.managed.provision(second.id, actor, { grantId: f.grantId, requestId: randomUUID() }); + expect((await service.get(second.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: secondApp }); + expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1); + expect(f.broker.claim).toHaveBeenCalledTimes(1); + }); + it("rejects missing tool scopes before activating a managed bot", async () => { + const f = await managedFixture(); f.state.grantedScopes = []; + await f.provision(); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_scopes_missing" }); + }); + it.each(["revoked", "attention", "active"] as const)("reinstalls the configured app on an explicit %s reconnect", async status => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatEndpoints).set({ status }).where(eq(chatEndpoints.id, f.endpoint.id)); + f.state.managedAccessToken = `xoxb-reconnected-${f.botId}`; f.state.rejectOldBotToken = true; + const reconnectInput = { ...f.input, requestId: randomUUID(), reconnect: true }; + await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0); + expect((await f.service.get(f.endpoint.id)).status).not.toBe("revoked"); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: f.appId }); + await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput); + await f.provision(); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + }); + it("preserves configured-app reconnect intent when managed installation fails", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatEndpoints).set({ status: "revoked" }).where(eq(chatEndpoints.id, f.endpoint.id)); + f.state.managedError = "temporary_error"; + await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", appId: f.appId }); + f.state.managedError = null; + await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0); + }); + it("reinstalls the saved app after missing bot scopes without creating another app", async () => { + const f = await managedFixture(); const scopes = f.state.grantedScopes; + f.state.grantedScopes = []; await f.provision(); + f.state.grantedScopes = scopes; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(2); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("retains reinstall intent after a provider failure instead of retrying rejected credentials", async () => { + const f = await managedFixture(); const scopes = f.state.grantedScopes; + f.state.grantedScopes = []; await f.provision(); + f.state.managedError = "internal_error"; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", errorCode: "slack_provider_failure" }); + f.state.managedError = null; f.state.grantedScopes = scopes; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(3); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("clears a manager reconnect error after renewing the bound grant", async () => { + const f = await managedFixture(); f.state.managedError = "invalid_auth"; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manager_reauthorize"); + await f.grants.complete(await f.authorize(), "new-claim", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBeNull(); + f.state.managedError = null; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("reconnects a rejected manager token without losing pending manifest configuration", async () => { + const f = await managedFixture(); f.state.failManifestUpdate = true; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manifest_update_pending"); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("reauthorize"); + await f.grants.complete(await f.authorize(), "new-claim", null, actor); + f.state.failManifestUpdate = false; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect((await f.service.get(f.endpoint.id)).setup.slackAvatar?.status).toBe("uploaded"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.icon.set"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("does not restore a revoked grant when an in-flight refresh returns", async () => { + const f = await managedFixture(); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.broker.refresh.mockImplementation(async () => { + await f.grants.revoke(f.grantId, f.companyId, actor); + return f.credentials; + }); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("revoked"); + }); + it("claims concurrent manager callbacks once", async () => { + const f = await managedFixture(); const state = await f.authorize(); + const results = await Promise.allSettled([1, 2].map(() => f.grants.complete(state, "claim", null, actor))); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(f.broker.claim).toHaveBeenCalledTimes(2); + }); + it("atomically consumes manager state and rejects wrong actors and expired attempts", async () => { + const f = await managedFixture(); + await expect(f.grants.complete(f.authorizeState, "replay", null, actor)).rejects.toThrow(); + const state = await f.authorize(); + await expect(f.grants.complete(state, "claim", null, { ...actor, userId: "another-user", bypassPermissionCheck: true })).rejects.toThrow(); + await db.update(toolOauthStates).set({ expiresAt: new Date(0) }).where(eq(toolOauthStates.state, state)); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + expect(f.broker.claim).toHaveBeenCalledTimes(1); + }); + it("preserves declined manager authorization as a safe recoverable outcome", async () => { + const f = await managedFixture(); + await f.grants.complete(await f.authorize(), null, "access_denied", actor); + expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBe("slack_install_declined"); + }); + it("resumes saved installation credentials after inventory failure without creating or reinstalling", async () => { + const f = await managedFixture(); f.state.failInventory = true; + await f.provision(); + expect((await f.service.slackRegistration.registration(f.endpoint.id))?.status).toBe("credentials_saved"); + f.state.failInventory = false; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("never recreates an uncertain app", async () => { + const f = await managedFixture(); f.state.failCreate = true; + await f.provision(); await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("uncertain"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("uses bound OAuth for approval fallback and rejects a different installer", async () => { + const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible"; + const result = await f.provision(); + const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!; + f.state.oauth.authed_user.id = "UOTHER"; + await f.service.slackRegistration.complete(state, "code-canary", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch"); + expect((await f.service.get(f.endpoint.id)).providerAccountId).toBeNull(); + }); + it("does not activate a bot when the manager grant is revoked during child OAuth", async () => { + const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible"; + const result = await f.provision(); + const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!; + f.state.beforeExchange = () => f.grants.revoke(f.grantId, f.companyId, actor); + await f.service.slackRegistration.complete(state, "code-canary", null, actor); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch"); + }); + it("uses the held transaction for grant queries without acquiring another pool connection", async () => { + const f = await managedFixture(); + const transactionContext = new AsyncLocalStorage(); + const originalTransaction = db.transaction.bind(db); + const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((work, ...args) => + originalTransaction(tx => transactionContext.run(true, () => work(tx)), ...args)); + const querySpies = (["select", "insert", "update", "delete", "execute"] as const).map(method => { + const original = db[method].bind(db) as (...args: unknown[]) => unknown; + return vi.spyOn(db, method).mockImplementation(((...args: unknown[]) => { + if (transactionContext.getStore()) throw new Error("Acquired outer pool connection from held transaction"); + return original(...args); + }) as never); + }); + try { + await f.grants.token(f.grantId, f.companyId, actor); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + await f.grants.token(f.grantId, f.companyId, actor); + expect(f.broker.refresh).toHaveBeenCalledTimes(1); + await f.grants.request(f.grantId, f.companyId, actor, "apps.manifest.validate", { manifest: "{}" }); + await f.grants.complete(await f.authorize(), "renewed-claim", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBeUndefined(); + await f.grants.revoke(f.grantId, f.companyId, actor); + expect(f.broker.revoke).toHaveBeenCalledTimes(2); + } finally { + transactionSpy.mockRestore(); + for (const spy of querySpies) spy.mockRestore(); + } + }); + it("serializes refreshes across endpoints and never replays an ambiguous refresh", async () => { + const f = await managedFixture(); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + const tokens = await Promise.all([1, 2].map(() => f.grants.token(f.grantId, f.companyId, actor))); + expect(tokens.every(result => result.token === "xoxe.rotated-canary")).toBe(true); + expect(f.broker.refresh).toHaveBeenCalledTimes(1); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.broker.refresh.mockRejectedValue(new Error("provider echoed manager-canary")); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + expect(f.broker.refresh).toHaveBeenCalledTimes(2); + }); + it("rejects a bot token belonging to a different Slack app before activation", async () => { + const f = await managedFixture(); f.state.managedBotAppId = "ADIFFERENT"; + await f.provision(); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_identity_mismatch" }); + }); + it("rejects manager identity changes and both callback-origin and ingress-origin drift", async () => { + const f = await managedFixture(); const state = await f.authorize(); + for (const change of [{ publicBaseUrl: "https://changed.example" }, { webhookPublicBaseUrl: "https://changed.example" }]) { + const changed = createService(new FakeChatSdkRuntime(), f.provider, { ...change, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } }).service; + await expect(changed.slackRegistration.managerGrants.complete(state, "claim", null, actor)).rejects.toThrow(); + } + f.broker.getSlackManagerAppId.mockResolvedValue("ANEWMANAGER"); + await expect(f.provision()).rejects.toThrow(); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + }); + it("preserves provider rate-limit backoff across subsequent attempts", async () => { + const f = await managedFixture(); f.state.managedError = "ratelimited"; + await f.provision(); + f.state.managedError = null; await f.provision(); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_setup_rate_limited"); + }); + it("routes the broker claim_id callback back to the saved wizard without exposing the handoff", async () => { + const f = await managedFixture(); const state = await f.authorize(); + const response = await request(f.app).get(`/api/chat-slack/managed/oauth/callback?state=${state}&claim_id=sealed-claim-canary`).set("accept", "application/json").expect(303); + expect(f.broker.claim.mock.calls.at(-1)![0].claimId).toBe("sealed-claim-canary"); + expect(response.headers.location).toContain(f.endpoint.id); + expect(response.text).not.toContain("sealed-claim-canary"); + expect(response.headers["cache-control"]).toBe("no-store"); + }); + it("does not accept another company's grant or keep authorizations after removal", async () => { + const f = await managedFixture(); const other = await seedCompany(); + await expect(f.grants.token(f.grantId, other.companyId, { ...actor, bypassPermissionCheck: true })).rejects.toThrow(); + const state = await f.authorize(); + await f.service.configure(f.endpoint.id, { action: "remove" }, actor.userId); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("active"); + }); + it("revocation invalidates pending authorization and prevents future provisioning", async () => { + const f = await managedFixture(); const state = await f.authorize(); + await f.grants.revoke(f.grantId, f.companyId, actor); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + await expect(f.provision()).rejects.toThrow(); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + }); + }); function setupEvent(f: Awaited>, patch: Record = {}, url = f.endpoint.setup.webhookUrl!, secret = signingSecret, timestamp?: string) { return signedSlackWebhookRequest({ url, contentType: "application/json", signingSecret: secret, ...(timestamp ? { timestamp } : {}), body: JSON.stringify({ type: "event_callback", api_app_id: f.appId, team_id: "TAUTO", event_id: "EvSETUP", event: { diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 6704cad5c5b..ab40ac0b2fd 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -522,6 +522,17 @@ describe("company portability", () => { })); }); + function exportCompanyAndAgents(portability: ReturnType) { + return portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + } + it("renders high-volume YAML blocks without overflowing the call stack", () => { const tasks = Array.from({ length: 130_000 }, (_, index) => `issue-${index}`); @@ -705,14 +716,7 @@ describe("company portability", () => { }; const portability = companyPortabilityService(db as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); const extension = asTextFile(exported.files[".paperclip.yaml"]); expect(extension).toContain("permissionGrants:"); @@ -794,14 +798,7 @@ describe("company portability", () => { }, ]); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); const serialized = JSON.stringify(exported); expect(serialized).not.toContain("sk-inline-secret-value"); @@ -1778,14 +1775,7 @@ describe("company portability", () => { it("reads env inputs back from .paperclip.yaml during preview import", async () => { const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); const preview = await portability.previewImport({ source: { @@ -3321,14 +3311,7 @@ describe("company portability", () => { }, ]); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); const extension = asTextFile(exported.files[".paperclip.yaml"]); expect(extension).toContain("APIKEY:"); @@ -3365,14 +3348,7 @@ describe("company portability", () => { reason: "Existing skill matched; renamed to paperclip-2.", }]); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -3445,14 +3421,7 @@ describe("company portability", () => { }); const portability = companyPortabilityService({} as any, storage as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); exported.files["images/company-logo.png"] = { encoding: "base64", @@ -3518,14 +3487,7 @@ describe("company portability", () => { name: "ClaudeCoder", }); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -3585,14 +3547,7 @@ describe("company portability", () => { }))); agentSvc.list.mockClear(); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -3637,14 +3592,7 @@ describe("company portability", () => { it("imports only selected files and leaves unchecked company metadata alone", async () => { const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); projectSvc.list.mockResolvedValue([]); @@ -3735,14 +3683,7 @@ describe("company portability", () => { name: "ClaudeCoder", }); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -3818,14 +3759,7 @@ describe("company portability", () => { adapterConfig: input.adapterConfig, })); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -5517,14 +5451,7 @@ describe("company portability", () => { name: "ClaudeCoder", }); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); const originalAgentsMarkdown = exported.files["agents/claudecoder/AGENTS.md"]; expect(typeof originalAgentsMarkdown).toBe("string"); @@ -5576,14 +5503,7 @@ describe("company portability", () => { it("rejects dangerous adapter types on agent-safe imports", async () => { const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); @@ -5760,14 +5680,7 @@ describe("company portability", () => { it("imports new agents as active while preserving future hire approval settings", async () => { const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); agentSvc.list.mockResolvedValue([]); secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ @@ -5836,14 +5749,7 @@ describe("company portability", () => { }, }))); agentSvc.list.mockClear(); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); + const exported = await exportCompanyAndAgents(portability); secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ normalized: "updated", diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index b18088c1cce..044595df762 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -201,6 +201,75 @@ function registerModuleMocks() { })); } +function boardActor(userId = "local-board", source = "local_implicit") { + return { + type: "board", + userId: userId, + companyIds: ["company-1"], + source: source, + isInstanceAdmin: false, + }; +} + +function assignedAgentActor() { + return { + type: "agent", + agentId: "55555555-5555-4555-8555-555555555555", + companyId: "company-1", + runId: "run-1", + }; +} + +function createCatalogSkill() { + return { + id: "paperclipai:bundled:software-development:review", + key: "paperclipai/bundled/software-development/review", + kind: "bundled", + category: "software-development", + slug: "review", + name: "review", + description: "Review code", + path: "catalog/bundled/software-development/review", + entrypoint: "SKILL.md", + trustLevel: "markdown_only", + compatibility: "compatible", + defaultInstall: false, + recommendedForRoles: ["engineer"], + requires: [], + tags: ["review"], + files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], + contentHash: "sha256:abc", + }; +} + +function createSkillTestRunDetails(overrides: Record = {}) { + return { + id: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + skillId: "skill-1", + inputId: "11111111-1111-4111-8111-111111111111", + inputSnapshot: "Try the skill", + skillVersionId: "33333333-3333-4333-8333-333333333333", + agentId: "55555555-5555-4555-8555-555555555555", + agentConfigSnapshot: { adapterType: "codex_local" }, + issueId: "44444444-4444-4444-8444-444444444444", + templateId: "built-in:default-test-template", + templateName: "Default test template", + templateBody: "Default {{skillName}}", + renderedTemplateBody: "Default Review", + harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", + status: "queued", + outputDocumentKey: "output", + outputSnapshot: "", + error: null, + deletedAt: null, + supersededAt: null, + harnessIssueExpiresAt: null, + harnessIssueDeletedAt: null, + ...overrides, + }; +} + describe("company skill mutation permissions", () => { const routeModules = hoistModuleGraph(registerModuleMocks, async () => { const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([ @@ -399,25 +468,7 @@ describe("company skill mutation permissions", () => { createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:00:00.000Z"), }, - catalogSkill: { - id: "paperclipai:bundled:software-development:review", - key: "paperclipai/bundled/software-development/review", - kind: "bundled", - category: "software-development", - slug: "review", - name: "review", - description: "Review code", - path: "catalog/bundled/software-development/review", - entrypoint: "SKILL.md", - trustLevel: "markdown_only", - compatibility: "compatible", - defaultInstall: false, - recommendedForRoles: ["engineer"], - requires: [], - tags: ["review"], - files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], - contentHash: "sha256:abc", - }, + catalogSkill: createCatalogSkill(), warnings: [], }); mockCompanySkillService.createLocalSkill.mockResolvedValue({ @@ -590,56 +641,14 @@ describe("company skill mutation permissions", () => { mockCompanySkillService.listTestRuns.mockResolvedValue([]); mockCompanySkillService.getTestRunDetail.mockResolvedValue(null); mockCompanySkillService.createTestRun.mockResolvedValue({ - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - skillId: "skill-1", - inputId: "11111111-1111-4111-8111-111111111111", - inputSnapshot: "Try the skill", - skillVersionId: "33333333-3333-4333-8333-333333333333", - agentId: "55555555-5555-4555-8555-555555555555", - agentConfigSnapshot: { adapterType: "codex_local" }, - issueId: "44444444-4444-4444-8444-444444444444", - templateId: "built-in:default-test-template", - templateName: "Default test template", - templateBody: "Default {{skillName}}", - renderedTemplateBody: "Default Review", - harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", - status: "queued", - outputDocumentKey: "output", - outputSnapshot: "", - error: null, - deletedAt: null, - supersededAt: null, - harnessIssueExpiresAt: null, - harnessIssueDeletedAt: null, + ...createSkillTestRunDetails(), createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:00:00.000Z"), cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, taskExpired: false, }); mockCompanySkillService.cancelTestRun.mockResolvedValue({ - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - skillId: "skill-1", - inputId: "11111111-1111-4111-8111-111111111111", - inputSnapshot: "Try the skill", - skillVersionId: "33333333-3333-4333-8333-333333333333", - agentId: "55555555-5555-4555-8555-555555555555", - agentConfigSnapshot: { adapterType: "codex_local" }, - issueId: "44444444-4444-4444-8444-444444444444", - templateId: "built-in:default-test-template", - templateName: "Default test template", - templateBody: "Default {{skillName}}", - renderedTemplateBody: "Default Review", - harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", - status: "cancelled", - outputDocumentKey: "output", - outputSnapshot: "", - error: "Cancelled by operator", - deletedAt: null, - supersededAt: null, - harnessIssueExpiresAt: null, - harnessIssueDeletedAt: null, + ...createSkillTestRunDetails({ status: "cancelled", error: "Cancelled by operator" }), createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:01:00.000Z"), cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -661,25 +670,7 @@ describe("company skill mutation permissions", () => { mockHeartbeatService.wakeup.mockResolvedValue({}); mockHeartbeatService.cancelRun.mockResolvedValue({}); mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([]); - mockCatalogService.getCatalogSkillOrThrow.mockReturnValue({ - id: "paperclipai:bundled:software-development:review", - key: "paperclipai/bundled/software-development/review", - kind: "bundled", - category: "software-development", - slug: "review", - name: "review", - description: "Review code", - path: "catalog/bundled/software-development/review", - entrypoint: "SKILL.md", - trustLevel: "markdown_only", - compatibility: "compatible", - defaultInstall: false, - recommendedForRoles: ["engineer"], - requires: [], - tags: ["review"], - files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], - contentHash: "sha256:abc", - }); + mockCatalogService.getCatalogSkillOrThrow.mockReturnValue(createCatalogSkill()); mockCatalogService.readCatalogSkillFile.mockResolvedValue({ catalogSkillId: "paperclipai:bundled:software-development:review", path: "SKILL.md", @@ -709,13 +700,7 @@ describe("company skill mutation permissions", () => { }); it("allows local board operators to mutate company skills", async () => { - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -750,13 +735,7 @@ describe("company skill mutation permissions", () => { }], warnings: [], }); - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const preview = await request(app) .post("/api/companies/company-1/skills/scan-projects") @@ -798,13 +777,7 @@ describe("company skill mutation permissions", () => { "Actor is restricted from changing skill configuration.", )); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/scan-projects") .send({ mode: "preview", workspaceIds: [workspaceId] }); @@ -814,13 +787,7 @@ describe("company skill mutation permissions", () => { }); it("allows board users with skills:create to create, import, install, update, delete, audit, and reset company skills", async () => { - const app = await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor("board-user", "session")); await request(app) .post("/api/companies/company-1/skills") @@ -867,13 +834,7 @@ describe("company skill mutation permissions", () => { it("allows board users without skills:create when no explicit skill policy exists", async () => { mockAccessService.decide.mockResolvedValue(denySkillChangeDecision()); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -897,12 +858,7 @@ describe("company skill mutation permissions", () => { remediation: "Contact a company administrator to change the skill policy.", }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://packages.example.com/skill.tgz" }); @@ -921,13 +877,7 @@ describe("company skill mutation permissions", () => { it("rejects secret-bearing remote import URLs without echoing the secret", async () => { const source = "https://github.com/acme/private-skill?token=secret#token=secret"; - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source }); @@ -941,13 +891,7 @@ describe("company skill mutation permissions", () => { }); it("rejects malformed remote import URLs before policy evaluation", async () => { - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://" }); @@ -965,12 +909,7 @@ describe("company skill mutation permissions", () => { "Low-trust agents cannot use company-wide skill APIs.", )); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -998,13 +937,7 @@ describe("company skill mutation permissions", () => { }; }); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/import") .send({ source: "vercel-labs/agent-browser" }); @@ -1042,13 +975,7 @@ describe("company skill mutation permissions", () => { } )); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .patch("/api/companies/company-1/skills/skill-1") .send({ name: "Updated review" }); @@ -1073,13 +1000,7 @@ describe("company skill mutation permissions", () => { }); mockCompanySkillPolicyService.evaluate.mockResolvedValue(denySkillPolicy("skills.create")); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/skill-1/versions") .send({ label: "v1" }); @@ -1110,13 +1031,7 @@ describe("company skill mutation permissions", () => { }; }); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/import") .send({ source: "npx skills add Vercel-Labs/Agent-Browser --skill agent-browser -g" }); @@ -1160,13 +1075,7 @@ describe("company skill mutation permissions", () => { }; }); - const res = await request(await createApp({ - type: "board", - userId: "board-user", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor("board-user", "session"))) .post("/api/companies/company-1/skills/import") .send({ source }); @@ -1185,34 +1094,10 @@ describe("company skill mutation permissions", () => { it("serves catalog listing without mutating company skills", async () => { mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([ - { - id: "paperclipai:bundled:software-development:review", - key: "paperclipai/bundled/software-development/review", - kind: "bundled", - category: "software-development", - slug: "review", - name: "review", - description: "Review code", - path: "catalog/bundled/software-development/review", - entrypoint: "SKILL.md", - trustLevel: "markdown_only", - compatibility: "compatible", - defaultInstall: false, - recommendedForRoles: ["engineer"], - requires: [], - tags: ["review"], - files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: "abc" }], - contentHash: "sha256:abc", - }, + createCatalogSkill(), ]); - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .get("/api/skills/catalog?kind=bundled&q=review"); expect(res.status, JSON.stringify(res.body)).toBe(200); @@ -1267,13 +1152,7 @@ describe("company skill mutation permissions", () => { }); it("serves catalog detail and files by catalog reference", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const detail = await request(app) .get("/api/skills/catalog/review"); @@ -1288,13 +1167,7 @@ describe("company skill mutation permissions", () => { }); it("installs catalog skills with mutation permissions and logs provenance", async () => { - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/install-catalog") .send({ catalogSkillId: "paperclipai:bundled:software-development:review", @@ -1348,13 +1221,7 @@ describe("company skill mutation permissions", () => { warnings: [], }); - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -1486,13 +1353,7 @@ describe("company skill mutation permissions", () => { warnings: [], }); - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://ghe.example.com/acme/private-skill" }); @@ -1528,13 +1389,7 @@ describe("company skill mutation permissions", () => { warnings: [], }); - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/acme/private-skill" }); @@ -1553,12 +1408,7 @@ describe("company skill mutation permissions", () => { permissions: { canCreateSkills: false }, }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -1578,12 +1428,7 @@ describe("company skill mutation permissions", () => { permissions: { canCreateSkills: true }, }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-2/skills/install-catalog") .send({ catalogSkillId: "paperclipai:bundled:software-development:review" }); @@ -1861,12 +1706,7 @@ describe("company skill mutation permissions", () => { permissions: { canCreateSkills: false }, }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -1906,26 +1746,7 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled(); }); - it("does not invoke the legacy consent gate under the open default", async () => { - mockAccessService.decide.mockResolvedValue(denySkillChangeDecision( - "deny_missing_consent", - "Permission skills:suggest-changes requires accepted change consent before applying this mutation.", - )); - mockReflectionCoachMutationGate.assertConsented.mockRejectedValue(new Error("database unavailable")); - const res = await request(await createApp({ - type: "agent", - agentId: "reflection-coach", - companyId: "company-1", - runId: "run-apply", - })) - .post("/api/companies/company-1/skills") - .send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" }); - - expect(res.status, JSON.stringify(res.body)).toBe(201); - expect(mockReflectionCoachMutationGate.assertConsented).not.toHaveBeenCalled(); - expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled(); - }); it("allows suggest-tier skill mutations without a second consent decision", async () => { mockAccessService.decide @@ -1940,12 +1761,7 @@ describe("company skill mutation permissions", () => { permissions: {}, }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -1966,12 +1782,7 @@ describe("company skill mutation permissions", () => { permissions: {}, }); - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) + const res = await request(await createApp(assignedAgentActor())) .post("/api/companies/company-1/skills/import") .send({ source: "https://github.com/vercel-labs/agent-browser" }); @@ -2016,13 +1827,7 @@ describe("company skill mutation permissions", () => { }); it("routes skill test input CRUD through skills mutation permissions", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const created = await request(app) .post("/api/companies/company-1/skills/skill-1/test-inputs") @@ -2057,13 +1862,7 @@ describe("company skill mutation permissions", () => { }); it("routes skill test run template CRUD through skills mutation permissions", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const listed = await request(app).get("/api/companies/company-1/skill-test-run-templates"); expect(listed.status, JSON.stringify(listed.body)).toBe(200); @@ -2124,28 +1923,7 @@ describe("company skill mutation permissions", () => { }); await deps.wakeHarnessIssue("44444444-4444-4444-8444-444444444444", "55555555-5555-4555-8555-555555555555"); return { - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - skillId: "skill-1", - inputId: "11111111-1111-4111-8111-111111111111", - inputSnapshot: "Try the skill", - skillVersionId: "33333333-3333-4333-8333-333333333333", - agentId: "55555555-5555-4555-8555-555555555555", - agentConfigSnapshot: { adapterType: "codex_local" }, - issueId: "44444444-4444-4444-8444-444444444444", - templateId: "built-in:default-test-template", - templateName: "Default test template", - templateBody: "Default {{skillName}}", - renderedTemplateBody: "Default Review", - harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", - status: "queued", - outputDocumentKey: "output", - outputSnapshot: "", - error: null, - deletedAt: null, - supersededAt: null, - harnessIssueExpiresAt: null, - harnessIssueDeletedAt: null, + ...createSkillTestRunDetails(), createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:00:00.000Z"), cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -2160,28 +1938,7 @@ describe("company skill mutation permissions", () => { ) => { await deps.cancelHarnessIssue("44444444-4444-4444-8444-444444444444"); return { - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - skillId: "skill-1", - inputId: "11111111-1111-4111-8111-111111111111", - inputSnapshot: "Try the skill", - skillVersionId: "33333333-3333-4333-8333-333333333333", - agentId: "55555555-5555-4555-8555-555555555555", - agentConfigSnapshot: { adapterType: "codex_local" }, - issueId: "44444444-4444-4444-8444-444444444444", - templateId: "built-in:default-test-template", - templateName: "Default test template", - templateBody: "Default {{skillName}}", - renderedTemplateBody: "Default Review", - harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", - status: "cancelled", - outputDocumentKey: "output", - outputSnapshot: "", - error: "Cancelled by operator", - deletedAt: null, - supersededAt: null, - harnessIssueExpiresAt: null, - harnessIssueDeletedAt: null, + ...createSkillTestRunDetails({ status: "cancelled", error: "Cancelled by operator" }), createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:01:00.000Z"), cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -2189,13 +1946,7 @@ describe("company skill mutation permissions", () => { }; }); - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const created = await request(app) .post("/api/companies/company-1/skills/skill-1/test-runs") @@ -2282,13 +2033,7 @@ describe("company skill mutation permissions", () => { harnessContent: { available: false, unavailableReason: "expired", documents: [], attachments: [], workProducts: [] }, }); - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const listed = await request(app) .get("/api/companies/company-1/skills/skill-1/test-runs"); @@ -2322,28 +2067,7 @@ describe("company skill mutation permissions", () => { ) => { await deps.hideHarnessIssue("44444444-4444-4444-8444-444444444444"); return { - id: "22222222-2222-4222-8222-222222222222", - companyId: "company-1", - skillId: "skill-1", - inputId: null, - inputSnapshot: "Try the skill", - skillVersionId: "33333333-3333-4333-8333-333333333333", - agentId: "55555555-5555-4555-8555-555555555555", - agentConfigSnapshot: { adapterType: "codex_local" }, - issueId: "44444444-4444-4444-8444-444444444444", - templateId: "built-in:default-test-template", - templateName: "Default test template", - templateBody: "Default {{skillName}}", - renderedTemplateBody: "Default Review", - harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", - status: "succeeded", - outputDocumentKey: "output", - outputSnapshot: "", - error: null, - deletedAt: new Date("2026-05-26T00:02:00.000Z"), - supersededAt: null, - harnessIssueExpiresAt: null, - harnessIssueDeletedAt: null, + ...createSkillTestRunDetails({ inputId: null, status: "succeeded", deletedAt: new Date("2026-05-26T00:02:00.000Z") }), createdAt: new Date("2026-05-26T00:00:00.000Z"), updatedAt: new Date("2026-05-26T00:02:00.000Z"), cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, @@ -2351,13 +2075,7 @@ describe("company skill mutation permissions", () => { }; }); - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const deleted = await request(app) .delete("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222"); @@ -2371,13 +2089,7 @@ describe("company skill mutation permissions", () => { it("returns 404 when deleting a missing test run", async () => { mockCompanySkillService.deleteTestRun.mockResolvedValueOnce(null); - const app = await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }); + const app = await createApp(boardActor()); const res = await request(app) .delete("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222"); expect(res.status).toBe(404); @@ -2391,13 +2103,7 @@ describe("company skill mutation permissions", () => { ); }); - const res = await request(await createApp({ - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - })) + const res = await request(await createApp(boardActor())) .delete("/api/companies/company-1/skills/skill-1"); expect(res.status, JSON.stringify(res.body)).toBe(422); diff --git a/server/src/__tests__/dev-runner-native-binary.test.ts b/server/src/__tests__/dev-runner-native-binary.test.ts index 1b3bfbf24f3..eba039ec3f6 100644 --- a/server/src/__tests__/dev-runner-native-binary.test.ts +++ b/server/src/__tests__/dev-runner-native-binary.test.ts @@ -149,7 +149,6 @@ describe("paperclip runner native dev prerequisite", () => { fs.utimesSync(target, current, current); const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture }; expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); - // A newer Linux build-host output cannot mask the remote Mac artifact. fs.writeFileSync(checkout.binary, nativeHeader("linux", "x64")); const wrongHostNewer = new Date(Date.now() + 4000); fs.utimesSync(checkout.binary, wrongHostNewer, wrongHostNewer); diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 773ce672a05..07c90052f37 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -223,11 +223,7 @@ function createDeleteBlastRadius(overrides: Partial<{ } let server: Server | null = null; -let currentActor: Record = { - type: "board", - userId: "user-1", - source: "local_implicit", -}; +let currentActor: Record = localBoardActor(); const routeOptions: Record = {}; const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER; @@ -260,6 +256,67 @@ function createApp(actor: Record, options: Record { afterAll(async () => { if (originalSecretsProviderEnv === undefined) { @@ -369,20 +426,7 @@ describe("environment routes", () => { ? { pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), } : null )); @@ -401,11 +445,7 @@ describe("environment routes", () => { it("lists instance-scoped environments through the company route alias", async () => { mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments?driver=local"); @@ -718,15 +758,7 @@ describe("environment routes", () => { expect(mockEnvironmentService.update).not.toHaveBeenCalled(); }); - it("still rejects deletion of the managed sandbox row", async () => { - mockEnvironmentService.getById.mockResolvedValue(createPlatformSandboxEnvironment()); - const app = createApp(ownerAdminActor); - const res = await request(app).delete("/api/environments/env-managed-1"); - - expect(res.status).toBe(403); - expect(res.body.details).toMatchObject({ code: "environment_platform_managed" }); - }); it("hides the local environment from every read surface under managed-sandbox-only", async () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true }); @@ -1173,11 +1205,7 @@ describe("environment routes", () => { }); it("returns provider capabilities for the company", async () => { - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments/capabilities"); @@ -1213,11 +1241,7 @@ describe("environment routes", () => { }, }, ]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments/capabilities"); @@ -1271,11 +1295,7 @@ describe("environment routes", () => { configSchema: { type: "object", properties: {} }, }, ]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments/capabilities"); @@ -1295,11 +1315,7 @@ describe("environment routes", () => { configSchema: { type: "object", properties: {} }, }, ]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments/capabilities"); @@ -1322,11 +1338,7 @@ describe("environment routes", () => { configSchema: { type: "object", properties: {} }, }, ]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/companies/company-1/environments/capabilities"); @@ -1384,11 +1396,7 @@ describe("environment routes", () => { it("creates an environment and logs activity", async () => { const environment = createEnvironment(); mockEnvironmentService.create.mockResolvedValue(environment); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .post("/api/companies/company-1/environments") @@ -1425,11 +1433,7 @@ describe("environment routes", () => { it("returns conflict when creating a second local environment", async () => { mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .post("/api/companies/company-1/environments") @@ -1529,11 +1533,7 @@ describe("environment routes", () => { mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({ isManagedLocal: true, })); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1"); @@ -1549,26 +1549,13 @@ describe("environment routes", () => { ...createEnvironment(), driver: "ssh" as const, name: "SSH Fixture", - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: null, - knownHosts: null, - strictHostKeyChecking: true, - }, + config: createSshConfig(), }; mockEnvironmentService.getById.mockResolvedValue(environment); mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({ isInstanceDefault: true, })); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1"); @@ -1585,26 +1572,13 @@ describe("environment routes", () => { ...createEnvironment(), driver: "ssh" as const, name: "SSH Fixture", - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: null, - knownHosts: null, - strictHostKeyChecking: true, - }, + config: createSshConfig(), }; mockEnvironmentService.getById.mockResolvedValue(environment); mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({ pendingCleanupLeaseCount: 1, })); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1"); @@ -1632,11 +1606,7 @@ describe("environment routes", () => { activeLeaseCount: 1, reusableSandboxLeaseCount: 1, })); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1"); @@ -1662,11 +1632,7 @@ describe("environment routes", () => { }); mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true"); @@ -1696,11 +1662,7 @@ describe("environment routes", () => { isInstanceDefault: true, reusableSandboxLeaseCount: 1, })); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true"); @@ -1722,11 +1684,7 @@ describe("environment routes", () => { failed: 1, skippedLiveRun: 0, }); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true"); @@ -1746,24 +1704,11 @@ describe("environment routes", () => { id: "env-ssh", driver: "ssh" as const, name: "SSH Fixture", - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: null, - knownHosts: null, - strictHostKeyChecking: true, - }, + config: createSshConfig(), }; mockEnvironmentService.getById.mockResolvedValue(environment); mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(true); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app) .patch("/api/environments/env-ssh") @@ -1790,25 +1735,12 @@ describe("environment routes", () => { id: "env-ssh", driver: "ssh" as const, name: "SSH Fixture", - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: null, - knownHosts: null, - strictHostKeyChecking: true, - }, + config: createSshConfig(), }; mockEnvironmentService.getById.mockResolvedValue(environment); mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(true); mockEnvironmentService.update.mockResolvedValue({ ...environment, description: "Updated" }); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app) .patch("/api/environments/env-ssh") @@ -1844,11 +1776,7 @@ describe("environment routes", () => { companyName: "Other Team", }, ]); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).get("/api/environments/env-sandbox/secret-refs"); @@ -1890,35 +1818,12 @@ describe("environment routes", () => { }); it("clears environment selections and secret bindings across all companies when deleting an environment", async () => { - const environment = { - ...createEnvironment(), - id: "env-ssh", - name: "SSH Fixture", - driver: "ssh" as const, - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: { - type: "secret_ref", - secretId: "11111111-1111-1111-1111-111111111111", - version: "latest", - }, - knownHosts: null, - strictHostKeyChecking: true, - }, - }; + const environment = createSecretBackedSshEnvironment(); mockEnvironmentService.getById.mockResolvedValue(environment); mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius()); mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]); - const app = createApp({ - type: "board", - userId: "admin-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor("admin-1")); const res = await request(app).delete("/api/environments/env-ssh"); @@ -1956,11 +1861,7 @@ describe("environment routes", () => { }); it("rejects invalid SSH config on create", async () => { - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .post("/api/companies/company-1/environments") @@ -1979,33 +1880,10 @@ describe("environment routes", () => { }); it("normalizes SSH private keys into secret refs before persistence", async () => { - const environment = { - ...createEnvironment(), - id: "env-ssh", - name: "SSH Fixture", - driver: "ssh" as const, - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: { - type: "secret_ref", - secretId: "11111111-1111-1111-1111-111111111111", - version: "latest", - }, - knownHosts: null, - strictHostKeyChecking: true, - }, - }; + const environment = createSecretBackedSshEnvironment(); mockEnvironmentService.create.mockResolvedValue(environment); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2045,32 +1923,9 @@ describe("environment routes", () => { it("uses the configured provider for SSH private key secret materialization", async () => { process.env.PAPERCLIP_SECRETS_PROVIDER = "aws_secrets_manager"; - const environment = { - ...createEnvironment(), - id: "env-ssh", - name: "SSH Fixture", - driver: "ssh" as const, - config: { - host: "ssh.example.test", - port: 22, - username: "ssh-user", - remoteWorkspacePath: "/srv/paperclip/workspace", - privateKey: null, - privateKeySecretRef: { - type: "secret_ref", - secretId: "11111111-1111-1111-1111-111111111111", - version: "latest", - }, - knownHosts: null, - strictHostKeyChecking: true, - }, - }; + const environment = createSecretBackedSshEnvironment(); mockEnvironmentService.create.mockResolvedValue(environment); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2097,11 +1952,7 @@ describe("environment routes", () => { }); it("rejects persisted fake sandbox environments", async () => { - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2134,11 +1985,7 @@ describe("environment routes", () => { }; mockEnvironmentService.create.mockResolvedValue(environment); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2223,11 +2070,7 @@ describe("environment routes", () => { }, ); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2277,27 +2120,10 @@ describe("environment routes", () => { }, pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), }); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2369,27 +2195,10 @@ describe("environment routes", () => { normalizedConfig: { ...config }, pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), })); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2452,27 +2261,10 @@ describe("environment routes", () => { }, pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), }); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2516,11 +2308,7 @@ describe("environment routes", () => { mockValidatePluginEnvironmentDriverConfig.mockResolvedValue(environment.config); mockEnvironmentService.create.mockResolvedValue(environment); const pluginWorkerManager = {}; - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager }); + const app = createApp(localBoardActor(), { pluginWorkerManager }); const res = await request(app) .post("/api/companies/company-1/environments") @@ -2604,11 +2392,7 @@ describe("environment routes", () => { updatedAt: new Date("2026-04-16T05:05:00.000Z"), }, ]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get(`/api/environments/${environment.id}/leases?status=active`); @@ -2640,11 +2424,7 @@ describe("environment routes", () => { createdAt: new Date("2026-04-16T05:00:00.000Z"), updatedAt: new Date("2026-04-16T05:05:00.000Z"), }); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app).get("/api/environment-leases/lease-1"); @@ -2677,11 +2457,7 @@ describe("environment routes", () => { ...environment, status: "archived", }); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .patch(`/api/environments/${environment.id}?companyId=company-1`) @@ -2736,11 +2512,7 @@ describe("environment routes", () => { driver: "local" as const, config: {}, }); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .patch(`/api/environments/${environment.id}?companyId=company-1`) @@ -2783,30 +2555,13 @@ describe("environment routes", () => { normalizedConfig: { ...config }, pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), })); // The environment's only binding still lives in another company. Before // bindings moved with the referenced secret, this state made every save // from the caller's company fail, with no route-level way out. mockSecretService.listBindingCompanyIdsForTarget.mockResolvedValue(["company-old"]); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager: {} }); + const app = createApp(localBoardActor(), { pluginWorkerManager: {} }); const res = await request(app) .patch("/api/environments/env-sandbox?companyId=company-new") @@ -2856,11 +2611,7 @@ describe("environment routes", () => { mockSecretService.replaceSecretRefsForInstanceTarget.mockRejectedValue( unprocessable("Secret referenced at apiKey was not found", { code: "secret_missing" }), ); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }, { pluginWorkerManager: {} }); + const app = createApp(localBoardActor(), { pluginWorkerManager: {} }); const res = await request(app) .patch("/api/environments/env-sandbox?companyId=company-1") @@ -2876,11 +2627,7 @@ describe("environment routes", () => { it("requires explicit SSH config when switching from local to SSH", async () => { mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .patch("/api/environments/env-1?companyId=company-1") @@ -2895,11 +2642,7 @@ describe("environment routes", () => { it("rejects switching an environment to the built-in fake sandbox provider", async () => { mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .patch("/api/environments/env-1?companyId=company-1") @@ -2918,11 +2661,7 @@ describe("environment routes", () => { it("returns 404 when patching a missing environment", async () => { mockEnvironmentService.getById.mockResolvedValue(null); - const app = createApp({ - type: "board", - userId: "user-1", - source: "local_implicit", - }); + const app = createApp(localBoardActor()); const res = await request(app) .patch("/api/environments/missing-env") @@ -3200,20 +2939,7 @@ describe("environment routes", () => { }, pluginId: "plugin-secure", pluginKey: "acme.secure-sandbox-provider", - driver: { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, - }, - }, + driver: createSecurePluginDriver(), }); mockProbeEnvironment.mockResolvedValue({ ok: true, diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index 18d7279abdd..e2cd5201336 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -241,6 +241,217 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { await stopDb?.(); }); + function insertSandboxProviderPlugin(pluginId: string, input: { + pluginKey?: string; + packageName?: string; + displayName?: string; + description?: string; + author?: string; + environmentDrivers?: Array>; + status?: string; + installOrder?: number; + } = {}) { + const pluginKey = input.pluginKey ?? "paperclip.fake-plugin-sandbox-provider"; + return db.insert(plugins).values({ + id: pluginId, + pluginKey, + packageName: input.packageName ?? "@paperclipai/plugin-fake-sandbox", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: pluginKey, + apiVersion: 1, + version: "1.0.0", + displayName: input.displayName ?? "Fake Plugin Sandbox Provider", + description: input.description ?? "Test fake plugin provider", + author: input.author ?? "Paperclip", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: input.environmentDrivers ?? [{ driverKey: "fake-plugin", kind: "sandbox_provider", displayName: "Fake Plugin", configSchema: { type: "object" } }], + }, + status: input.status ?? "ready", + installOrder: input.installOrder ?? 1, + updatedAt: new Date(), + } as any); + } + + function insertForeignCompanyEnvironmentBinding(otherCompanyId: string, environment: { id: string }) { + return db.insert(builtInManagedResources).values({ + companyId: otherCompanyId, + bundleKey: "managed-environment", + resourceKind: "environment", + resourceKey: "managed-sandbox", + resourceId: environment.id, + stockVersion: "1", + stockHash: "hash", + }); + } + + function insertReusableProject(projectId: string, companyId: string) { + return db.insert(projects).values({ + id: projectId, + companyId, + name: `Workspace ${projectId.slice(0, 8)}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + function createFreshPluginLeaseWorker(pluginId: string) { + return { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string) => { + if (method === "environmentAcquireLease") { + return { + providerLeaseId: "fresh-plugin-lease", + metadata: { + provider: "fake-plugin", + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + remoteCwd: "/workspace", + }, + }; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })), + } as unknown as PluginWorkerManager; + } + + function insertReusableExecutionWorkspace(executionWorkspaceId: string, companyId: string, projectId: string) { + return db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Reusable workspace", + status: "active", + providerType: "local_fs", + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + function insertStaleReusablePluginLease(companyId: string, environment: { id: string }, executionWorkspaceId: string, runId: string, agentId: string, pluginId: string, providerConfig: Record) { + return environmentService(db).acquireLease({ + companyId, + environmentId: environment.id, + executionWorkspaceId, + heartbeatRunId: runId, + leasePolicy: "reuse_by_environment", + provider: "fake-plugin", + providerLeaseId: "stale-plugin-lease", + metadata: { + agentId, + driver: "sandbox", + pluginId, + pluginKey: "acme.fake-sandbox-provider", + sandboxProviderPlugin: true, + provider: "fake-plugin", + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + reusableSandboxLease: { + version: 1, + companyId, + environmentId: environment.id, + executionWorkspaceId, + agentId, + adapterType: null, + provider: "fake-plugin", + runtimeFingerprint: reusableRuntimeFingerprint({ + provider: "fake-plugin", + adapterType: null, + config: providerConfig, + }), + }, + }, + }); + } + + function createReferencedWorkspaceRealizationRequest(companyId: string, environment: { id: string }, runId: string) { + return { + version: 1, + adapterType: "codex_local", + companyId, + environmentId: environment.id, + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: runId, + requestedMode: "ephemeral", + source: { + kind: "project_primary", + localPath: "/tmp/anchor", + projectId: "anchor-project", + projectWorkspaceId: "anchor-workspace", + repoUrl: null, + repoRef: null, + strategy: "project_primary", + branchName: null, + worktreePath: null, + }, + additionalSources: [ + { + localPath: "/tmp/referenced-project", + projectId: "referenced-project-1", + projectWorkspaceId: "referenced-workspace-1", + repoUrl: null, + repoRef: null, + }, + ], + }; + } + + function insertOldReusablePluginLease(companyId: string, environment: { id: string }, executionWorkspaceId: string, runId: string, agentId: string, providerConfig: Record) { + return environmentService(db).acquireLease({ + companyId, + environmentId: environment.id, + executionWorkspaceId, + heartbeatRunId: runId, + leasePolicy: "reuse_by_environment", + provider: "fake-plugin", + providerLeaseId: "old-plugin-lease", + metadata: { + agentId, + provider: "fake-plugin", + image: "fake:test", + timeoutMs: 1234, + reuseLease: true, + reusableSandboxLease: { + version: 1, + companyId, + environmentId: environment.id, + executionWorkspaceId, + agentId, + adapterType: null, + provider: "fake-plugin", + runtimeFingerprint: reusableRuntimeFingerprint({ + provider: "fake-plugin", + adapterType: null, + config: providerConfig, + }), + }, + }, + }); + } + + function insertRunningManualRun(nextRunId: string, companyId: string, agentId: string) { + return db.insert(heartbeatRuns).values({ + id: nextRunId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + createdAt: new Date(), + updatedAt: new Date(), + }); + } + async function seedEnvironment(input: { driver?: string; name?: string; @@ -376,44 +587,28 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.reusable-sandbox-provider", packageName: "@acme/reusable-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.reusable-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Reusable Sandbox Provider", - description: "Test provider with reusable lease support", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Reusable Sandbox Provider", + description: "Test provider with reusable lease support", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); await db.insert(projects).values({ @@ -1427,15 +1622,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const destroySpy = vi.spyOn(sandboxProviderRuntime, "destroySandboxProviderLease"); try { @@ -1496,36 +1683,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: pluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); // A managed reconciliation binds the environment to another company after the // route guard read an empty binding list. The lease insert then rejects the @@ -1539,15 +1697,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -1623,15 +1773,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The remote teardown fails, so the acquire cannot release the sandbox // directly. It must record a durable pending-cleanup row instead. @@ -1681,15 +1823,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The teardown reads the lease table when it runs. A durable pending_cleanup // row must already exist, which proves the acquire records the row before the @@ -1751,15 +1885,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // Force every durable pending-cleanup write to fail, but let the teardown // succeed. A successful teardown removes the orphan, so the acquire needs no @@ -1837,36 +1963,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: pluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); const otherCompanyId = randomUUID(); await db.insert(companies).values({ id: otherCompanyId, @@ -1876,15 +1973,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -1944,15 +2033,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The remote teardown fails, so the acquire tries to record a durable // pending-cleanup row instead. @@ -2056,15 +2137,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The remote teardown fails, so the acquire tries to record a durable // pending-cleanup row instead. @@ -2155,15 +2228,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The remote teardown fails, so the acquire tries to record a durable // pending-cleanup row instead. @@ -2275,15 +2340,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The remote teardown fails, so the acquire tries to record a durable // pending-cleanup row instead. @@ -2409,15 +2466,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const destroySpy = vi .spyOn(sandboxProviderRuntime, "destroySandboxProviderLease") @@ -2520,15 +2569,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const destroySpy = vi .spyOn(sandboxProviderRuntime, "destroySandboxProviderLease") @@ -2628,36 +2669,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: pluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); const otherCompanyId = randomUUID(); await db.insert(companies).values({ id: otherCompanyId, @@ -2667,15 +2679,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -2798,15 +2802,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The first teardown fails, so the acquire records a pending-cleanup orphan. // The sweep then retries the teardown, which succeeds this time. @@ -2862,15 +2858,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const destroySpy = vi .spyOn(sandboxProviderRuntime, "destroySandboxProviderLease") @@ -2934,15 +2922,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); const destroySpy = vi .spyOn(sandboxProviderRuntime, "destroySandboxProviderLease") @@ -2995,36 +2975,20 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: "Cleanup Worker Readiness", config: { provider: "fake-plugin-ready", image: "fake:test", reuseLease: false }, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "paperclip.fake-plugin-ready-sandbox-provider", packageName: "@paperclipai/plugin-fake-ready-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-ready-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Ready Sandbox Provider", - description: "Test fake plugin provider readiness", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin-ready", - kind: "sandbox_provider", - displayName: "Fake Plugin Ready", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Fake Plugin Ready Sandbox Provider", + description: "Test fake plugin provider readiness", + environmentDrivers: [ + { + driverKey: "fake-plugin-ready", + kind: "sandbox_provider", + displayName: "Fake Plugin Ready", + configSchema: { type: "object" }, + }, + ], + }); // The worker is down at first, then recovers on the second probe. let workerRunning = false; @@ -3094,37 +3058,21 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: "Cleanup Plugin Readiness", config: { provider: "fake-plugin-reload", image: "fake:test", reuseLease: false }, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "paperclip.fake-plugin-reload-sandbox-provider", packageName: "@paperclipai/plugin-fake-reload-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-reload-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Reload Sandbox Provider", - description: "Test fake plugin provider reload readiness", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin-reload", - kind: "sandbox_provider", - displayName: "Fake Plugin Reload", - configSchema: { type: "object" }, - }, - ], - }, - // The plugin starts in a not-ready status, as during a reload. + displayName: "Fake Plugin Reload Sandbox Provider", + description: "Test fake plugin provider reload readiness", + environmentDrivers: [ + { + driverKey: "fake-plugin-reload", + kind: "sandbox_provider", + displayName: "Fake Plugin Reload", + configSchema: { type: "object" }, + }, + ], status: "installing", - installOrder: 1, - updatedAt: new Date(), - } as any); + }); // The worker runs the whole time, so the probe result depends on the plugin // status alone, not on a down worker. @@ -3212,36 +3160,20 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { // The plugin reinstall completes, so a ready plugin row now resolves the // provider and the probe reports ready. - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "paperclip.fake-plugin-missing-sandbox-provider", packageName: "@paperclipai/plugin-fake-missing-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-missing-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Missing Sandbox Provider", - description: "Test fake plugin provider missing readiness", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin-missing", - kind: "sandbox_provider", - displayName: "Fake Plugin Missing", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Fake Plugin Missing Sandbox Provider", + description: "Test fake plugin provider missing readiness", + environmentDrivers: [ + { + driverKey: "fake-plugin-missing", + kind: "sandbox_provider", + displayName: "Fake Plugin Missing", + configSchema: { type: "object" }, + }, + ], + }); await expect( runtimeWithPlugin.isPendingCleanupWorkerReady({ environment, lease }), ).resolves.toBe(true); @@ -3602,36 +3534,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: pluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); const otherCompanyId = randomUUID(); await db.insert(companies).values({ id: otherCompanyId, @@ -3641,15 +3544,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await db.insert(builtInManagedResources).values({ - companyId: otherCompanyId, - bundleKey: "managed-environment", - resourceKind: "environment", - resourceKey: "managed-sandbox", - resourceId: environment.id, - stockVersion: "1", - stockHash: "hash", - }); + await insertForeignCompanyEnvironmentBinding(otherCompanyId, environment); // The first destroy fails, so the acquire records a pending-cleanup orphan. // The sweep retries the destroy, which succeeds this time. @@ -3726,36 +3621,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: fakePluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), call: vi.fn(async (_pluginId: string, method: string, params: any) => { @@ -3871,36 +3737,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: fakePluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); return { companyId, environment, runId, pluginId }; } @@ -4190,36 +4027,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: fakePluginConfig, }); - await db.insert(plugins).values({ - id: pluginId, - pluginKey: "paperclip.fake-plugin-sandbox-provider", - packageName: "@paperclipai/plugin-fake-sandbox", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "paperclip.fake-plugin-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Plugin Sandbox Provider", - description: "Test fake plugin provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + await insertSandboxProviderPlugin(pluginId); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), call: vi.fn(async (_pluginId: string, method: string) => { @@ -4259,36 +4067,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { heartbeatRunId: runId, persistedExecutionWorkspace: null, }); - const workspaceRealizationRequest = { - version: 1, - adapterType: "codex_local", - companyId, - environmentId: environment.id, - executionWorkspaceId: null, - issueId: null, - heartbeatRunId: runId, - requestedMode: "ephemeral", - source: { - kind: "project_primary", - localPath: "/tmp/anchor", - projectId: "anchor-project", - projectWorkspaceId: "anchor-workspace", - repoUrl: null, - repoRef: null, - strategy: "project_primary", - branchName: null, - worktreePath: null, - }, - additionalSources: [ - { - localPath: "/tmp/referenced-project", - projectId: "referenced-project-1", - projectWorkspaceId: "referenced-workspace-1", - repoUrl: null, - repoRef: null, - }, - ], - }; + const workspaceRealizationRequest = createReferencedWorkspaceRealizationRequest(companyId, environment, runId); const realized = await runtimeWithPlugin.realizeWorkspace({ environment, lease: acquired.lease, @@ -4338,36 +4117,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { persistedExecutionWorkspace: null, }); - const workspaceRealizationRequest = { - version: 1, - adapterType: "codex_local", - companyId, - environmentId: environment.id, - executionWorkspaceId: null, - issueId: null, - heartbeatRunId: runId, - requestedMode: "ephemeral", - source: { - kind: "project_primary", - localPath: "/tmp/anchor", - projectId: "anchor-project", - projectWorkspaceId: "anchor-workspace", - repoUrl: null, - repoRef: null, - strategy: "project_primary", - branchName: null, - worktreePath: null, - }, - additionalSources: [ - { - localPath: "/tmp/referenced-project", - projectId: "referenced-project-1", - projectWorkspaceId: "referenced-workspace-1", - repoUrl: null, - repoRef: null, - }, - ], - }; + const workspaceRealizationRequest = createReferencedWorkspaceRealizationRequest(companyId, environment, runId); const realized = await runtime.realizeWorkspace({ environment, lease: acquired.lease, @@ -4423,44 +4173,28 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.secure-sandbox-provider", packageName: "@acme/secure-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.secure-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Secure Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Secure Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), call: vi.fn(async (_pluginId: string, method: string, params: any) => { @@ -4577,44 +4311,28 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.secure-sandbox-provider", packageName: "@acme/secure-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.secure-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Secure Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Secure Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); @@ -4646,36 +4364,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.eventually-running-sandbox-provider", packageName: "@acme/eventually-running-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.eventually-running-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Eventually Running Sandbox Provider", - description: "Test plugin worker startup grace period", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Eventually Running Sandbox Provider", + description: "Test plugin worker startup grace period", + author: "Acme", + }); let runningChecks = 0; const workerManager = { @@ -4739,36 +4434,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.never-running-sandbox-provider", packageName: "@acme/never-running-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.never-running-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Never Running Sandbox Provider", - description: "Test plugin worker that never comes online before the readiness deadline", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Never Running Sandbox Provider", + description: "Test plugin worker that never comes online before the readiness deadline", + author: "Acme", + }); const workerManager = { isRunning: vi.fn(() => false), @@ -4817,36 +4489,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.registered-late-sandbox-provider", packageName: "@acme/registered-late-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.registered-late-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Registered Late Sandbox Provider", - description: "Test plugin worker handle that stays unregistered/starting for most of the readiness window", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Registered Late Sandbox Provider", + description: "Test plugin worker handle that stays unregistered/starting for most of the readiness window", + author: "Acme", + }); // Simulates a worker process restart: the handle is absent (or "starting") // for most of the readiness window and only reports running with several @@ -4922,39 +4571,23 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.long-lease-sandbox-provider", packageName: "@acme/long-lease-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.long-lease-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Long Lease Sandbox Provider", - description: "Test plugin worker acquire timeout", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - defaultAcquireTimeoutMs: declared, - // A timeoutMs schema default can mean lease lifetime (for example - // E2B). Only the dedicated declaration sets the host RPC budget. - configSchema: { type: "object", properties: { timeoutMs: { type: "number", default: 3_600_000 } } }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Long Lease Sandbox Provider", + description: "Test plugin worker acquire timeout", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + defaultAcquireTimeoutMs: declared, + // A timeoutMs schema default can mean lease lifetime (for example + // E2B). Only the dedicated declaration sets the host RPC budget. + configSchema: { type: "object", properties: { timeoutMs: { type: "number", default: 3_600_000 } } }, + }, + ], + }); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -5020,100 +4653,33 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.fake-sandbox-provider", packageName: "@acme/fake-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.fake-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Fake Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Reusable workspace", - status: "active", - providerType: "local_fs", - createdAt: new Date(), - updatedAt: new Date(), - }); - const staleLease = await environmentService(db).acquireLease({ - companyId, - environmentId: environment.id, - executionWorkspaceId, - heartbeatRunId: runId, - leasePolicy: "reuse_by_environment", - provider: "fake-plugin", - providerLeaseId: "stale-plugin-lease", - metadata: { - agentId, - driver: "sandbox", - pluginId, - pluginKey: "acme.fake-sandbox-provider", - sandboxProviderPlugin: true, - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - reusableSandboxLease: { - version: 1, - companyId, - environmentId: environment.id, - executionWorkspaceId, - agentId, - adapterType: null, - provider: "fake-plugin", - runtimeFingerprint: reusableRuntimeFingerprint({ - provider: "fake-plugin", - adapterType: null, - config: providerConfig, - }), - }, - }, - }); + await insertReusableProject(projectId, companyId); + await insertReusableExecutionWorkspace(executionWorkspaceId, companyId, projectId); + const staleLease = await insertStaleReusablePluginLease(companyId, environment, executionWorkspaceId, runId, agentId, pluginId, providerConfig); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -5472,100 +5038,33 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.fake-sandbox-provider", packageName: "@acme/fake-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.fake-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Fake Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Reusable workspace", - status: "active", - providerType: "local_fs", - createdAt: new Date(), - updatedAt: new Date(), - }); - const staleLease = await environmentService(db).acquireLease({ - companyId, - environmentId: environment.id, - executionWorkspaceId, - heartbeatRunId: runId, - leasePolicy: "reuse_by_environment", - provider: "fake-plugin", - providerLeaseId: "stale-plugin-lease", - metadata: { - agentId, - driver: "sandbox", - pluginId, - pluginKey: "acme.fake-sandbox-provider", - sandboxProviderPlugin: true, - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - reusableSandboxLease: { - version: 1, - companyId, - environmentId: environment.id, - executionWorkspaceId, - agentId, - adapterType: null, - provider: "fake-plugin", - runtimeFingerprint: reusableRuntimeFingerprint({ - provider: "fake-plugin", - adapterType: null, - config: providerConfig, - }), - }, - }, - }); + await insertReusableProject(projectId, companyId); + await insertReusableExecutionWorkspace(executionWorkspaceId, companyId, projectId); + const staleLease = await insertStaleReusablePluginLease(companyId, environment, executionWorkspaceId, runId, agentId, pluginId, providerConfig); // The runtime reads the worker methods once to decide reuse, then does // asynchronous database work before the resume dispatch. A worker restart @@ -5677,37 +5176,21 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.fake-sandbox-provider", packageName: "@acme/fake-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.fake-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Fake Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { type: "object" }, + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); await db.insert(projects).values({ @@ -5855,66 +5338,32 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.fake-sandbox-provider", packageName: "@acme/fake-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.fake-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Fake Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Fake Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Reusable workspace", - status: "active", - providerType: "local_fs", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); + await insertReusableExecutionWorkspace(executionWorkspaceId, companyId, projectId); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -5961,15 +5410,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { await runtimeWithPlugin.releaseRunLeases(runId); const nextRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: nextRunId, - companyId, - agentId, - invocationSource: "manual", - status: "running", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertRunningManualRun(nextRunId, companyId, agentId); const updatedEnvironment = { ...environment, config: { @@ -6043,67 +5484,33 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.secure-sandbox-provider", packageName: "@acme/secure-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.secure-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Secure Sandbox Provider", - description: "Test schema-driven provider", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "secure-plugin", - kind: "sandbox_provider", - displayName: "Secure Sandbox", - supportsReusableLeases: true, - configSchema: { - type: "object", - properties: { - template: { type: "string" }, - apiKey: { type: "string", format: "secret-ref" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Secure Sandbox Provider", + description: "Test schema-driven provider", + environmentDrivers: [ + { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + supportsReusableLeases: true, + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - mode: "shared_workspace", - strategyType: "project_primary", - name: "Reusable workspace", - status: "active", - providerType: "local_fs", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); + await insertReusableExecutionWorkspace(executionWorkspaceId, companyId, projectId); const workerManager = { isRunning: vi.fn((id: string) => id === pluginId), @@ -6145,15 +5552,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { await secretService(db).rotate(apiSecret.id, { value: "rotated-provider-key" }); const nextRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: nextRunId, - companyId, - agentId, - invocationSource: "manual", - status: "running", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertRunningManualRun(nextRunId, companyId, agentId); const second = await runtimeWithPlugin.acquireRunLease({ companyId, environment, @@ -6190,34 +5589,8 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { const { pluginId, companyId, agentId, environment, executionWorkspaceId, reusableLease } = await seedReusablePluginSandboxLease(); const nextRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: nextRunId, - companyId, - agentId, - invocationSource: "manual", - status: "running", - createdAt: new Date(), - updatedAt: new Date(), - }); - const workerManager = { - isRunning: vi.fn((id: string) => id === pluginId), - call: vi.fn(async (_pluginId: string, method: string) => { - if (method === "environmentAcquireLease") { - return { - providerLeaseId: "fresh-plugin-lease", - metadata: { - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - remoteCwd: "/workspace", - }, - }; - } - throw new Error(`Unexpected plugin method: ${method}`); - }), - getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })), - } as unknown as PluginWorkerManager; + await insertRunningManualRun(nextRunId, companyId, agentId); + const workerManager = createFreshPluginLeaseWorker(pluginId); const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); const acquired = await runtimeWithPlugin.acquireRunLease({ @@ -6265,46 +5638,15 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.non-reusable-sandbox-provider", packageName: "@acme/non-reusable-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.non-reusable-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Non-reusable Sandbox Provider", - description: "Test provider without reusable lease support", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Non-reusable Sandbox Provider", + description: "Test provider without reusable lease support", + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -6317,56 +5659,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await environmentService(db).acquireLease({ - companyId, - environmentId: environment.id, - executionWorkspaceId, - heartbeatRunId: runId, - leasePolicy: "reuse_by_environment", - provider: "fake-plugin", - providerLeaseId: "old-plugin-lease", - metadata: { - agentId, - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - reusableSandboxLease: { - version: 1, - companyId, - environmentId: environment.id, - executionWorkspaceId, - agentId, - adapterType: null, - provider: "fake-plugin", - runtimeFingerprint: reusableRuntimeFingerprint({ - provider: "fake-plugin", - adapterType: null, - config: providerConfig, - }), - }, - }, - }); + await insertOldReusablePluginLease(companyId, environment, executionWorkspaceId, runId, agentId, providerConfig); - const workerManager = { - isRunning: vi.fn((id: string) => id === pluginId), - call: vi.fn(async (_pluginId: string, method: string) => { - if (method === "environmentAcquireLease") { - return { - providerLeaseId: "fresh-plugin-lease", - metadata: { - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - remoteCwd: "/workspace", - }, - }; - } - throw new Error(`Unexpected plugin method: ${method}`); - }), - getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })), - } as unknown as PluginWorkerManager; + const workerManager = createFreshPluginLeaseWorker(pluginId); const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); const acquired = await runtimeWithPlugin.acquireRunLease({ @@ -6411,55 +5706,32 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.nested-disabled-sandbox-provider", packageName: "@acme/nested-disabled-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.nested-disabled-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Nested-disabled Sandbox Provider", - description: "Test provider with a legacy flag and a disabled nested capability", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - sandboxCapabilities: { reusableLeases: false }, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Nested-disabled Sandbox Provider", + description: "Test provider with a legacy flag and a disabled nested capability", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + sandboxCapabilities: { reusableLeases: false }, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -6472,56 +5744,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { createdAt: new Date(), updatedAt: new Date(), }); - await environmentService(db).acquireLease({ - companyId, - environmentId: environment.id, - executionWorkspaceId, - heartbeatRunId: runId, - leasePolicy: "reuse_by_environment", - provider: "fake-plugin", - providerLeaseId: "old-plugin-lease", - metadata: { - agentId, - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - reusableSandboxLease: { - version: 1, - companyId, - environmentId: environment.id, - executionWorkspaceId, - agentId, - adapterType: null, - provider: "fake-plugin", - runtimeFingerprint: reusableRuntimeFingerprint({ - provider: "fake-plugin", - adapterType: null, - config: providerConfig, - }), - }, - }, - }); + await insertOldReusablePluginLease(companyId, environment, executionWorkspaceId, runId, agentId, providerConfig); - const workerManager = { - isRunning: vi.fn((id: string) => id === pluginId), - call: vi.fn(async (_pluginId: string, method: string) => { - if (method === "environmentAcquireLease") { - return { - providerLeaseId: "fresh-plugin-lease", - metadata: { - provider: "fake-plugin", - image: "fake:test", - timeoutMs: 1234, - reuseLease: true, - remoteCwd: "/workspace", - }, - }; - } - throw new Error(`Unexpected plugin method: ${method}`); - }), - getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })), - } as unknown as PluginWorkerManager; + const workerManager = createFreshPluginLeaseWorker(pluginId); const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); const acquired = await runtimeWithPlugin.acquireRunLease({ @@ -6573,54 +5798,31 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { name: environment.name, config: providerConfig, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.unverified-worker-sandbox-provider", packageName: "@acme/unverified-worker-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.unverified-worker-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Unverified-worker Sandbox Provider", - description: "Test provider that declares reusable leases but whose worker lacks the reuse methods", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - sandboxCapabilities: { reusableLeases: true }, - configSchema: { - type: "object", - properties: { - image: { type: "string" }, - timeoutMs: { type: "number" }, - reuseLease: { type: "boolean" }, - }, + displayName: "Unverified-worker Sandbox Provider", + description: "Test provider that declares reusable leases but whose worker lacks the reuse methods", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + sandboxCapabilities: { reusableLeases: true }, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, }, }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + }, + ], + }); const executionWorkspaceId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -7189,37 +6391,23 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { // driver key alone would read this declaration and grant a capability the // owner plugin denied. const collidingPluginId = randomUUID(); - await db.insert(plugins).values({ - id: collidingPluginId, + await insertSandboxProviderPlugin(collidingPluginId, { pluginKey: "acme.colliding-sandbox-provider", packageName: "@acme/colliding-sandbox-provider", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.colliding-sandbox-provider", - apiVersion: 1, - version: "1.0.0", - displayName: "Colliding Sandbox Provider", - description: "Earlier plugin that shares the driver key", - author: "Paperclip", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - supportsReusableLeases: true, - configSchema: { type: "object", properties: {} }, - }, - ], - }, + displayName: "Colliding Sandbox Provider", + description: "Earlier plugin that shares the driver key", + environmentDrivers: [ + { + driverKey: "fake-plugin", + kind: "sandbox_provider", + displayName: "Fake Plugin", + supportsReusableLeases: true, + configSchema: { type: "object", properties: {} }, + }, + ], status: "installed", installOrder: 0, - updatedAt: new Date(), - } as any); + }); // The owner worker verifies the reuse verbs and the sync verbs, so every // capability is verified. Only the owner's declaration can narrow one. @@ -7454,14 +6642,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { const pluginId = randomUUID(); const projectId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: `Workspace ${projectId.slice(0, 8)}`, - status: "active", - createdAt: new Date(), - updatedAt: new Date(), - }); + await insertReusableProject(projectId, companyId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -7475,36 +6656,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { updatedAt: new Date(), }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.environments", packageName: "@acme/paperclip-environments", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.environments", - apiVersion: 1, - version: "1.0.0", - displayName: "Acme Environments", - description: "Test plugin environment driver", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - kind: "sandbox_provider", - displayName: "Fake Plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + }); await environmentService(db).acquireLease({ companyId, @@ -7601,36 +6759,21 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.environments", packageName: "@acme/paperclip-environments", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.environments", - apiVersion: 1, - version: "1.0.0", - displayName: "Acme Environments", - description: "Test plugin environment driver", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - displayName: "Fake plugin", - defaultAcquireTimeoutMs, - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + environmentDrivers: [ + { + driverKey: "fake-plugin", + displayName: "Fake plugin", + defaultAcquireTimeoutMs, + configSchema: { type: "object" }, + }, + ], + }); const acquired = await runtimeWithPlugin.acquireRunLease({ companyId, @@ -7708,31 +6851,16 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { driverConfig: { template: "base" }, }, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.environments", packageName: "@acme/paperclip-environments", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.environments", - apiVersion: 1, - version: "1.0.0", - displayName: "Acme Environments", - description: "Test plugin environment driver", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { driverKey: "fake-plugin", displayName: "Fake plugin", configSchema: { type: "object" } }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + environmentDrivers: [ + { driverKey: "fake-plugin", displayName: "Fake plugin", configSchema: { type: "object" } }, + ], + }); return seeded; } @@ -7863,35 +6991,20 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }, }); - await db.insert(plugins).values({ - id: pluginId, + await insertSandboxProviderPlugin(pluginId, { pluginKey: "acme.environments", packageName: "@acme/paperclip-environments", - version: "1.0.0", - apiVersion: 1, - categories: ["automation"], - manifestJson: { - id: "acme.environments", - apiVersion: 1, - version: "1.0.0", - displayName: "Acme Environments", - description: "Test plugin environment driver", - author: "Acme", - categories: ["automation"], - capabilities: ["environment.drivers.register"], - entrypoints: { worker: "dist/worker.js" }, - environmentDrivers: [ - { - driverKey: "fake-plugin", - displayName: "Fake plugin", - configSchema: { type: "object" }, - }, - ], - }, - status: "ready", - installOrder: 1, - updatedAt: new Date(), - } as any); + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + environmentDrivers: [ + { + driverKey: "fake-plugin", + displayName: "Fake plugin", + configSchema: { type: "object" }, + }, + ], + }); const acquired = await runtimeWithPlugin.acquireRunLease({ companyId, diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index 4f953121e34..50d10441fae 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -154,8 +154,6 @@ describeEmbeddedPostgres("environmentService leases", () => { config: { config: { ...config, image: `registry.example.test/runtime@sha256:${"b".repeat(64)}` } }, driver: { driver: "ssh", config: { host: "fixture.example.test", username: "fixture" } }, }; - // Interleave the real probe UPDATE after the save is prepared but before its - // real UPDATE executes. This reproduces the stale route-read/write window. const delayedWriteDb = { update(table: typeof environments) { const prepared = db.update(table); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 504d9ba82f8..68c0ea20779 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -292,6 +292,104 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { await tempDb?.cleanup(); }); + function insertBlockedSourceTask(issueId: string, companyId: string, projectId: string) { + return db.insert(issues).values({ + id: issueId, + companyId, + projectId, + title: "Source task", + status: "blocked", + priority: "medium", + }); + } + + function insertBoardWorkspaceRecovery(companyId: string, issueId: string, fingerprint: string) { + return db.insert(issueRecoveryActions).values({ + companyId, + sourceIssueId: issueId, + kind: "workspace_validation", + status: "active", + ownerType: "board", + cause: "workspace_validation_failed", + fingerprint, + evidence: { + workspaceValidation: { + fingerprint, + }, + }, + nextAction: "Repair the source issue workspace link.", + }); + } + + function insertMissingRecordedBranchWorkspace(executionWorkspaceId: string, companyId: string, projectId: string, issueId: string, worktreePath: string) { + return db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + sourceIssueId: issueId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "feature/never-created", + status: "idle", + providerType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + branchName: "feature/never-created", + baseRef: "main", + }); + } + + function insertRecordedBranchWorkspace(executionWorkspaceId: string, companyId: string, projectId: string, projectWorkspaceId: string, issueId: string, worktreePath: string) { + return db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + sourceIssueId: issueId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "feature/recorded", + status: "active", + providerType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + branchName: "feature/recorded", + baseRef: "main", + }); + } + + function insertRunningWorkspaceService(runtimeServiceId: string, companyId: string, projectId: string, executionWorkspaceId: string, issueId: string, worktreePath: string) { + return db.insert(workspaceRuntimeServices).values({ + id: runtimeServiceId, + companyId, + projectId, + executionWorkspaceId, + issueId, + scopeType: "execution_workspace", + serviceName: "web", + status: "running", + lifecycle: "shared", + command: "pnpm dev", + cwd: worktreePath, + provider: "local_process", + healthStatus: "healthy", + }); + } + + function insertActiveCodexCoder(agentId: string, companyId: string) { + return db.insert(agents).values({ + id: agentId, + companyId, + name: "Codex Coder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + } + async function seedTerminalWorkspace(options: { mergedPr?: boolean; activeRun?: boolean; @@ -2202,21 +2300,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { branchName: "feature/recorded", baseRef: "main", }); - await db.insert(issueRecoveryActions).values({ - companyId, - sourceIssueId: issueId, - kind: "workspace_validation", - status: "active", - ownerType: "board", - cause: "workspace_validation_failed", - fingerprint, - evidence: { - workspaceValidation: { - fingerprint, - }, - }, - nextAction: "Repair the source issue workspace link.", - }); + await insertBoardWorkspaceRecovery(companyId, issueId, fingerprint); const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "forward", @@ -2309,21 +2393,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { status: "blocked", priority: "medium", }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - sourceIssueId: issueId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "feature/never-created", - status: "idle", - providerType: "git_worktree", - cwd: worktreePath, - providerRef: worktreePath, - branchName: "feature/never-created", - baseRef: "main", - }); + await insertMissingRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, issueId, worktreePath); const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "forward", @@ -2390,21 +2460,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { status: "blocked", priority: "medium", }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - sourceIssueId: issueId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "feature/never-created", - status: "idle", - providerType: "git_worktree", - cwd: worktreePath, - providerRef: worktreePath, - branchName: "feature/never-created", - baseRef: "main", - }); + await insertMissingRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, issueId, worktreePath); await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "forward", @@ -2463,21 +2519,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { status: "blocked", priority: "medium", }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - sourceIssueId: issueId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "feature/never-created", - status: "idle", - providerType: "git_worktree", - cwd: worktreePath, - providerRef: worktreePath, - branchName: "feature/never-created", - baseRef: "main", - }); + await insertMissingRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, issueId, worktreePath); await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "forward", @@ -2527,17 +2569,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { issuePrefix: "PAP", requireBoardApprovalForNewAgents: false, }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Codex Coder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await insertActiveCodexCoder(agentId, companyId); await db.insert(projects).values({ id: projectId, companyId, @@ -2563,37 +2595,8 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { priority: "medium", assigneeAgentId: agentId, }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - projectWorkspaceId, - sourceIssueId: issueId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "feature/recorded", - status: "active", - providerType: "git_worktree", - cwd: worktreePath, - providerRef: worktreePath, - branchName: "feature/recorded", - baseRef: "main", - }); - await db.insert(issueRecoveryActions).values({ - companyId, - sourceIssueId: issueId, - kind: "workspace_validation", - status: "active", - ownerType: "board", - cause: "workspace_validation_failed", - fingerprint, - evidence: { - workspaceValidation: { - fingerprint, - }, - }, - nextAction: "Repair the source issue workspace link.", - }); + await insertRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, projectWorkspaceId, issueId, worktreePath); + await insertBoardWorkspaceRecovery(companyId, issueId, fingerprint); const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "quarantine_restore", @@ -2717,21 +2720,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { branchName: "feature/recorded", baseRef: "main", }); - await db.insert(workspaceRuntimeServices).values({ - id: runtimeServiceId, - companyId, - projectId, - executionWorkspaceId, - issueId, - scopeType: "execution_workspace", - serviceName: "web", - status: "running", - lifecycle: "shared", - command: "pnpm dev", - cwd: worktreePath, - provider: "local_process", - healthStatus: "healthy", - }); + await insertRunningWorkspaceService(runtimeServiceId, companyId, projectId, executionWorkspaceId, issueId, worktreePath); await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "quarantine_restore", @@ -2875,37 +2864,8 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { lastDecisionOutcome: null, }, }); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - projectWorkspaceId, - sourceIssueId: issueId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "feature/recorded", - status: "active", - providerType: "git_worktree", - cwd: worktreePath, - providerRef: worktreePath, - branchName: "feature/recorded", - baseRef: "main", - }); - await db.insert(issueRecoveryActions).values({ - companyId, - sourceIssueId: issueId, - kind: "workspace_validation", - status: "active", - ownerType: "board", - cause: "workspace_validation_failed", - fingerprint, - evidence: { - workspaceValidation: { - fingerprint, - }, - }, - nextAction: "Repair the source issue workspace link.", - }); + await insertRecordedBranchWorkspace(executionWorkspaceId, companyId, projectId, projectWorkspaceId, issueId, worktreePath); + await insertBoardWorkspaceRecovery(companyId, issueId, fingerprint); const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "quarantine_restore", @@ -2986,17 +2946,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { issuePrefix: "PAP", requireBoardApprovalForNewAgents: false, }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Codex Coder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await insertActiveCodexCoder(agentId, companyId); await db.insert(projects).values({ id: projectId, companyId, @@ -3159,14 +3109,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3244,14 +3187,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3329,14 +3265,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3433,14 +3362,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3456,21 +3378,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { branchName: "feature/recorded", baseRef: "main", }); - await db.insert(workspaceRuntimeServices).values({ - id: runtimeServiceId, - companyId, - projectId, - executionWorkspaceId, - issueId, - scopeType: "execution_workspace", - serviceName: "web", - status: "running", - lifecycle: "shared", - command: "pnpm dev", - cwd: worktreePath, - provider: "local_process", - healthStatus: "healthy", - }); + await insertRunningWorkspaceService(runtimeServiceId, companyId, projectId, executionWorkspaceId, issueId, worktreePath); await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "override", @@ -3540,14 +3448,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3669,14 +3570,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -3852,14 +3746,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { name: "Branch reconcile", status: "in_progress", }); - await db.insert(issues).values({ - id: issueId, - companyId, - projectId, - title: "Source task", - status: "blocked", - priority: "medium", - }); + await insertBlockedSourceTask(issueId, companyId, projectId); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, diff --git a/server/src/__tests__/http-log-policy.test.ts b/server/src/__tests__/http-log-policy.test.ts index f6a92125283..0c57d5159b0 100644 --- a/server/src/__tests__/http-log-policy.test.ts +++ b/server/src/__tests__/http-log-policy.test.ts @@ -65,7 +65,7 @@ describe("isPrivateWebhookHttpRequest", () => { describe("isSecretSensitiveHttpRequest", () => { it("protects Slack registration, installation, recovery, and all callback methods", () => { for (const suffix of ["registration", "install", "resume"]) expect(isSecretSensitiveHttpRequest("POST", `/api/chat-endpoints/id/slack/${suffix}`)).toBe(true); - for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"]) + for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "/api/chat-slack/managed/oauth/callback?claim=canary", "/api/chat-slack/managed/oauth/callback?code=canary", "/api/chat-endpoints/endpoint/slack/managed/authorize", "/api/chat-endpoints/endpoint/slack/managed/provision", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"]) expect(isSecretSensitiveHttpRequest(method, path)).toBe(true); }); it("identifies credential-bearing chat setup mutations", () => { diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 5d6cf9d0345..140046ad425 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -68,6 +68,15 @@ const mockDb = { select: vi.fn(() => ({ from: () => Promise.resolve(mockCompanyRows) })), }; +function localAdminActor() { + return { + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }; +} + describe("instance settings routes", () => { const routeModules = hoistModuleGraph(registerModuleMocks, async () => { const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([ @@ -234,12 +243,7 @@ describe("instance settings routes", () => { }); it("allows local board users to read and update experimental settings", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const getRes = await request(app).get("/api/instance/settings/experimental"); expect(getRes.status).toBe(200); @@ -273,12 +277,7 @@ describe("instance settings routes", () => { }, 10_000); it("does not expose the retired liveness auto-recovery endpoints", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview") @@ -303,12 +302,7 @@ describe("instance settings routes", () => { }); it("accepts the instance-wide Streamlined UI preference", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -321,12 +315,7 @@ describe("instance settings routes", () => { }); it("strips server-managed worktree run execution fields before updating experimental settings", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -343,12 +332,7 @@ describe("instance settings routes", () => { }); it("allows local board users to read and update the instance default environment", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const getRes = await request(app).get("/api/instance/settings"); expect(getRes.status).toBe(200); @@ -379,12 +363,7 @@ describe("instance settings routes", () => { envVars: {}, metadata: { managedByPaperclip: true, managedDefaultStamped: true }, }); - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const patchRes = await request(app) .patch("/api/instance/settings") @@ -419,12 +398,7 @@ describe("instance settings routes", () => { metadata: { managedByPaperclip: true, managedDefaultStamped: true }, }); mockEnvironmentService.update.mockRejectedValue(new Error("metadata write failed")); - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const patchRes = await request(app) .patch("/api/instance/settings") @@ -436,12 +410,7 @@ describe("instance settings routes", () => { it("rejects unknown defaultEnvironmentId values with 422", async () => { mockEnvironmentService.getById.mockResolvedValue(null); - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const res = await request(app) .patch("/api/instance/settings") @@ -453,12 +422,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update guarded dev-server auto-restart", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -473,12 +437,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update external object detection", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -491,12 +450,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update built-in agents", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -509,12 +463,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update the goals sidebar link", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -527,12 +476,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update the server info debug view", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -545,12 +489,7 @@ describe("instance settings routes", () => { }); it("allows local board users to update environment controls", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); await request(app) .patch("/api/instance/settings/experimental") @@ -582,12 +521,7 @@ describe("instance settings routes", () => { }); it("allows local board users to read and update general settings", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); + const app = await createApp(localAdminActor()); const getRes = await request(app).get("/api/instance/settings/general"); expect(getRes.status).toBe(200); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 42b1f4de12c..0043cd24623 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -321,6 +321,56 @@ async function waitForWakeup(assertion: () => void) { await vi.waitFor(assertion); } +function createAgentReviewPolicy(reviewerAgentId: string) { + return normalizePolicy({ + stages: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + type: "review", + participants: [{ type: "agent", agentId: reviewerAgentId }], + }, + ], + }); +} + +function createReviewIssue(policy: NonNullable>>, reviewerAgentId: string) { + return { + ...makeIssue("todo"), + status: "in_review", + assigneeAgentId: reviewerAgentId, + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: policy.stages[0].id, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: reviewerAgentId }, + returnAssignee: { + type: "agent", + agentId: "22222222-2222-4222-8222-222222222222", + }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }; +} + +function createScheduledRetry() { + return { + runId: "retry-run-1", + status: "scheduled_retry", + agentId: "22222222-2222-4222-8222-222222222222", + agentName: "CodexCoder", + retryOfRunId: "source-run-1", + scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), + scheduledRetryAttempt: 1, + scheduledRetryReason: "transient_failure", + error: null, + errorCode: null, + }; +} + describe("issue comment reopen routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -956,7 +1006,7 @@ describe("issue comment reopen routes", () => { // Same guard on cancelled status — explicit resume must use `resume: true`, // a log-class self-comment with `reopen: true` is not a reopen signal. - it("does not reopen via POST comment+reopen when the assignee agent is the actor on a cancelled issue", async () => { + it("rejects bare agent reopen requests on cancelled issues before writing a comment", async () => { const assigneeAgentId = "22222222-2222-4222-8222-222222222222"; mockIssueService.getById.mockResolvedValue(makeIssue("cancelled")); mockIssueService.update.mockImplementation( @@ -985,14 +1035,13 @@ describe("issue comment reopen routes", () => { }), ) .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") - // Cancelled issues reject explicit resume entirely, so only reopen=true - // is observable here — the guard is what keeps it from flipping back. .send({ body: "log line", reopen: true }); - // Cancelled issues are rejected at assertExplicitResumeIntentAllowed for - // agent actors with reopen=true (409). The guard runs after that, but - // either way no reopen wakeup must fire and no status update to todo. - expect([200, 201, 409]).toContain(res.status); + expect(res.status).toBe(409); + expect(res.body.error).toBe( + "Cancelled issues must be restored through the dedicated restore flow", + ); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); expect(mockIssueService.update).not.toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ status: "todo" }), @@ -1142,18 +1191,7 @@ describe("issue comment reopen routes", () => { executionRunId: "retry-run-1", }; mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ - runId: "retry-run-1", - status: "scheduled_retry", - agentId: "22222222-2222-4222-8222-222222222222", - agentName: "CodexCoder", - retryOfRunId: "source-run-1", - scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), - scheduledRetryAttempt: 1, - scheduledRetryReason: "transient_failure", - error: null, - errorCode: null, - }); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry()); mockIssueService.update.mockImplementation( async (_id: string, patch: Record) => ({ ...issue, @@ -1214,18 +1252,7 @@ describe("issue comment reopen routes", () => { executionRunId: "retry-run-1", }; mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ - runId: "retry-run-1", - status: "scheduled_retry", - agentId: "22222222-2222-4222-8222-222222222222", - agentName: "CodexCoder", - retryOfRunId: "source-run-1", - scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), - scheduledRetryAttempt: 1, - scheduledRetryReason: "transient_failure", - error: null, - errorCode: null, - }); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry()); mockHeartbeatService.cancelRun.mockRejectedValue( new Error("cancel failed"), ); @@ -1890,18 +1917,7 @@ describe("issue comment reopen routes", () => { executionRunId: "retry-run-1", }; mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ - runId: "retry-run-1", - status: "scheduled_retry", - agentId: "22222222-2222-4222-8222-222222222222", - agentName: "CodexCoder", - retryOfRunId: "source-run-1", - scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), - scheduledRetryAttempt: 1, - scheduledRetryReason: "transient_failure", - error: null, - errorCode: null, - }); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry()); mockIssueService.update.mockImplementation( async (_id: string, patch: Record) => ({ ...issue, @@ -1950,18 +1966,7 @@ describe("issue comment reopen routes", () => { executionRunId: "retry-run-1", }; mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ - runId: "retry-run-1", - status: "scheduled_retry", - agentId: "22222222-2222-4222-8222-222222222222", - agentName: "CodexCoder", - retryOfRunId: "source-run-1", - scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), - scheduledRetryAttempt: 1, - scheduledRetryReason: "transient_failure", - error: null, - errorCode: null, - }); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(createScheduledRetry()); mockHeartbeatService.cancelRun.mockRejectedValue( new Error("cancel failed"), ); @@ -2635,8 +2640,8 @@ describe("issue comment reopen routes", () => { }, ); - it.each(["invalid", "wrong agent", "wrong company"])( - "rejects comment and PATCH writes with a %s run", + it( + "rejects comment and PATCH writes when influence validation rejects the run", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("todo")); mockObserveCrossIssueInfluence.mockRejectedValue( @@ -2940,35 +2945,8 @@ describe("issue comment reopen routes", () => { it("auto-approves a reviewer comment with the APPROVED review marker", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good."; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3032,35 +3010,8 @@ describe("issue comment reopen routes", () => { it("auto-approves a reviewer comment with structured review metadata", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "kind: review\ndecision: approved\nsummary: ship it"; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3125,15 +3076,7 @@ describe("issue comment reopen routes", () => { it("auto-approves a reviewer comment and wakes dependents when the final blocker resolves", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; const dependentAgentId = "44444444-4444-4444-8444-444444444444"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; const issue = { ...makeIssue("todo"), status: "in_review", @@ -3220,15 +3163,7 @@ describe("issue comment reopen routes", () => { it("does not wake the returnAssignee with issue_commented when auto-approval reassigns the issue", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; const returnAssigneeAgentId = "22222222-2222-4222-8222-222222222222"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; const issue = { ...makeIssue("todo"), status: "in_review", @@ -3298,35 +3233,8 @@ describe("issue comment reopen routes", () => { it("does not auto-approve APPROVED comments from a non-review participant", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good."; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3422,35 +3330,8 @@ describe("issue comment reopen routes", () => { // Even though both `kind: review` and `decision: approved` appear, a blank line between // them means they are not on truly consecutive lines, so the strict regex must reject it. const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "kind: review\n\ndecision: approved"; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3483,35 +3364,8 @@ describe("issue comment reopen routes", () => { it("does not auto-approve reviewer comments without an approval marker", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "Looks good."; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3545,15 +3399,7 @@ describe("issue comment reopen routes", () => { it("does not auto-approve approval comments outside the in_review status", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; const issue = { ...makeIssue("todo"), status: "in_progress", @@ -3633,35 +3479,8 @@ describe("issue comment reopen routes", () => { ])("does not auto-approve negated approval phrasings ($name)", ({ body }) => { it("rejects the auto-approval transition and keeps the comment as a regular comment", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ id: "comment-review-negated", @@ -3705,35 +3524,8 @@ describe("issue comment reopen routes", () => { ])("auto-approves positive approval phrasings ($name)", ({ body }) => { it("triggers the auto-approval transition", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ id: "comment-review-positive", @@ -3794,35 +3586,8 @@ describe("issue comment reopen routes", () => { it("rolls back the comment when the auto-approval status transition fails", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good."; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ @@ -3869,35 +3634,8 @@ describe("issue comment reopen routes", () => { it("rolls back the auto-approval comment when the issue is concurrently deleted", async () => { const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const policy = await normalizePolicy({ - stages: [ - { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - ], - })!; - const issue = { - ...makeIssue("todo"), - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: policy.stages[0].id, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { - type: "agent", - agentId: "22222222-2222-4222-8222-222222222222", - }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; + const policy = await createAgentReviewPolicy(reviewerAgentId)!; + const issue = createReviewIssue(policy, reviewerAgentId); const reviewBody = "## Review: PAP-580 - APPROVED\n\nLooks good."; mockIssueService.getById.mockResolvedValue(issue); mockIssueService.addComment.mockResolvedValue({ diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index c6c0b072540..04225ce2c88 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -81,17 +81,55 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { await tempDb?.cleanup(); }); + function interactionCompanyRow(companyId: string): typeof companies.$inferInsert { + return { + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }; + } + + function parentIssueRow(issueId: string, companyId: string, goalId: string): typeof issues.$inferInsert { + return { + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }; + } + + function acceptIssueScope(issueId: string, companyId: string, goalId: string) { + return { + id: issueId, + companyId, + goalId, + projectId: null, + }; + } + + function activeCodexAgentRow(agentId: string, companyId: string, name: string, role: string): typeof agents.$inferInsert { + return { + id: agentId, + companyId, + name, + role, + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }; + } + async function seedConfirmationIssue(title = "Comment supersede") { const companyId = randomUUID(); const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -100,14 +138,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); return { companyId, goalId, issueId }; } @@ -174,17 +205,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const { companyId, issueId } = await seedConfirmationIssue("Source question race"); const agentId = randomUUID(); const runId = randomUUID(); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Questioner", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Questioner", "engineer")); await db.insert(heartbeatRuns).values({ id: runId, companyId, @@ -596,17 +617,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { it("rejects an explicitly mismatched source-run issue or agent", async () => { const fixture = await seedSourceQuestionFixture({ paperclipWake: { comments: [] } }); const otherAgentId = randomUUID(); - await db.insert(agents).values({ - id: otherAgentId, - companyId: fixture.companyId, - name: "Other questioner", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(otherAgentId, fixture.companyId, "Other questioner", "engineer")); // Simulate historical corruption to retain the service-level defense test. await db.transaction(async (tx) => { @@ -764,17 +775,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const agentId = randomUUID(); const firstRunId = randomUUID(); const secondRunId = randomUUID(); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Researcher", - role: "researcher", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Researcher", "researcher")); await db.update(issues).set({ assigneeAgentId: agentId, status: "in_progress" }).where(eq(issues.id, issueId)); await db.insert(heartbeatRuns).values([ { @@ -1168,12 +1169,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const assigneeAgentId = randomUUID(); const responsibleUserId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -1183,17 +1179,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(agents).values({ - id: assigneeAgentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(assigneeAgentId, companyId, "CodexCoder", "engineer")); await db.insert(issues).values({ id: issueId, companyId, @@ -1233,12 +1219,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(created.status).toBe("pending"); - const accepted = await interactionsSvc.acceptSuggestedTasks({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + const accepted = await interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", }); @@ -1295,12 +1276,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(listed).toHaveLength(1); expect(listed[0]?.status).toBe("accepted"); - await expect(interactionsSvc.acceptSuggestedTasks({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + await expect(interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", })).rejects.toThrow("Interaction has already been resolved"); @@ -1313,12 +1289,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -1366,12 +1337,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { userId: "local-board", }); - const accepted = await interactionsSvc.acceptSuggestedTasks({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + const accepted = await interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedClientKeys: ["root"], }, { userId: "local-board", @@ -1395,12 +1361,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -1410,14 +1371,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); const created = await interactionsSvc.create({ id: issueId, @@ -1444,12 +1398,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); await expect( - interactionsSvc.acceptSuggestedTasks({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + interactionsSvc.acceptSuggestedTasks(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedClientKeys: ["child"], }, { userId: "local-board", @@ -1462,12 +1411,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -1564,12 +1508,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -1951,12 +1890,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const agentId = randomUUID(); const runId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -1966,25 +1900,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "CodexCoder", "engineer")); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); await db.insert(heartbeatRuns).values({ id: runId, companyId, @@ -2039,28 +1956,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const firstAgentId = randomUUID(); const secondAgentId = randomUUID(); await db.insert(agents).values([ - { - id: firstAgentId, - companyId, - name: "First agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, - { - id: secondAgentId, - companyId, - name: "Second agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, + activeCodexAgentRow(firstAgentId, companyId, "First agent", "engineer"), + activeCodexAgentRow(secondAgentId, companyId, "Second agent", "engineer"), ]); const older = await interactionsSvc.create({ id: issueId, companyId }, { @@ -2117,28 +2014,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const probingAgentId = randomUUID(); const otherAgentId = randomUUID(); await db.insert(agents).values([ - { - id: probingAgentId, - companyId, - name: "Probing agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, - { - id: otherAgentId, - companyId, - name: "Other agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, + activeCodexAgentRow(probingAgentId, companyId, "Probing agent", "engineer"), + activeCodexAgentRow(otherAgentId, companyId, "Other agent", "engineer"), ]); const question = (prompt: string) => ({ @@ -2198,23 +2075,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); const agentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Chief of staff", - role: "chief_of_staff", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Chief of staff", "chief_of_staff")); await db.insert(goals).values({ id: goalId, companyId, @@ -2291,28 +2153,8 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const firstAgentId = randomUUID(); const secondAgentId = randomUUID(); await db.insert(agents).values([ - { - id: firstAgentId, - companyId, - name: "First agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, - { - id: secondAgentId, - companyId, - name: "Second agent", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, + activeCodexAgentRow(firstAgentId, companyId, "First agent", "engineer"), + activeCodexAgentRow(secondAgentId, companyId, "Second agent", "engineer"), ]); const firstAgentIds = [randomUUID(), randomUUID(), randomUUID()]; @@ -2411,12 +2253,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const goalId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -2425,14 +2262,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); const created = await interactionsSvc.create({ id: issueId, @@ -2454,12 +2284,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(created.kind).toBe("request_confirmation"); expect(created.status).toBe("pending"); - const accepted = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", }); @@ -2551,17 +2376,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const resolverAgentId = randomUUID(); const resolverRunId = randomUUID(); await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId)); - await db.insert(agents).values({ - id: resolverAgentId, - companyId, - name: "Review agent", - role: "reviewer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer")); await db.insert(heartbeatRuns).values({ id: resolverRunId, companyId, @@ -2579,12 +2394,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); await recordReviewTransition({ companyId, issueId, interactionId: created.id }); - const accepted = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { agentId: resolverAgentId, runId: resolverRunId, resolverPolicyRestriction: "anyone", @@ -2604,17 +2414,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const { companyId, goalId, issueId } = await seedConfirmationIssue(`Locked ${action} policy`); const resolverAgentId = randomUUID(); const resolverRunId = randomUUID(); - await db.insert(agents).values({ - id: resolverAgentId, - companyId, - name: "Review agent", - role: "reviewer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer")); await db.insert(heartbeatRuns).values({ id: resolverRunId, companyId, @@ -2705,17 +2505,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const resolverAgentId = randomUUID(); const resolverRunId = randomUUID(); await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId)); - await db.insert(agents).values({ - id: resolverAgentId, - companyId, - name: "Review agent", - role: "reviewer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(resolverAgentId, companyId, "Review agent", "reviewer")); await db.insert(heartbeatRuns).values({ id: resolverRunId, companyId, @@ -2806,12 +2596,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }, }); - const accepted = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: ["file-c", "file-a"], }, { userId: "local-board", @@ -2854,34 +2639,19 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { userId: "local-board", }); - await expect(interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: [], }, { userId: "local-board", })).rejects.toThrow("Select at least 1 checkbox confirmation option(s)"); - await expect(interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: ["missing"], }, { userId: "local-board", })).rejects.toThrow("Unknown checkbox confirmation optionId: missing"); - await expect(interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, { + await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, { selectedOptionIds: ["one", "two", "three"], }, { userId: "local-board", @@ -3217,12 +2987,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const issueId = randomUUID(); const agentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -3231,17 +2996,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Senior Product Engineer", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Senior Product Engineer", "engineer")); await db.insert(issues).values({ id: issueId, companyId, @@ -3268,12 +3023,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { agentId, }); - const accepted = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", }); @@ -3315,12 +3065,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { agentId, }); - const resumed = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, agentOwnedConfirmation.id, {}, { + const resumed = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), agentOwnedConfirmation.id, {}, { userId: "local-board", }); @@ -3358,12 +3103,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { agentId, }); - const acceptedWhileActive = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, activeConfirmation.id, {}, { + const acceptedWhileActive = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), activeConfirmation.id, {}, { userId: "local-board", }); @@ -3380,17 +3120,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { it("atomically returns an accepted Plan-mode issue to its agent in Auto mode", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Accept a plan into Auto mode"); const agentId = randomUUID(); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Plan owner", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Plan owner", "engineer")); await db.update(issues).set({ status: "in_review", workMode: "planning", @@ -3403,12 +3133,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Accept this plan?", target }, }, { agentId }); - const accepted = await interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { userId: "local-board" }); + const accepted = await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board" }); expect(accepted.interaction).toMatchObject({ id: created.id, @@ -3433,17 +3158,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { it("keeps Plan mode for non-plan and checkbox confirmations", async () => { const { companyId, goalId, issueId } = await seedConfirmationIssue("Do not auto-transition other confirmations"); const agentId = randomUUID(); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "Plan owner", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "Plan owner", "engineer")); await db.update(issues).set({ status: "in_review", workMode: "planning", @@ -3454,7 +3169,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { kind: "request_confirmation", payload: { version: 1, prompt: "Accept this unrelated decision?" }, }, { agentId }); - await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, nonPlan.id, {}, { + await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), nonPlan.id, {}, { userId: "local-board", }); await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) @@ -3471,7 +3186,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { target, }, }, { agentId }); - await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, checkbox.id, { + await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), checkbox.id, { selectedOptionIds: ["phase-1"], }, { userId: "local-board" }); await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) @@ -3487,7 +3202,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Accept this plan?", target }, }, { userId: "local-board" }); - await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, created.id, {}, { + await interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", }); @@ -3929,12 +3644,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const revisionId = randomUUID(); const nextRevisionId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -3943,14 +3653,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); await db.insert(documents).values({ id: documentId, companyId, @@ -4013,12 +3716,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { latestRevisionNumber: 2, }); - await expect(interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", })).rejects.toMatchObject({ status: 409, @@ -4057,12 +3755,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const revisionId = randomUUID(); const nextRevisionId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -4071,14 +3764,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); await db.update(issues).set({ workMode: "planning" }).where(eq(issues.id, issueId)); // Document is already at revision 2 — revision 1 is stale. await db.insert(documents).values({ @@ -4172,12 +3858,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { userId: "local-board", }); expect(created).toMatchObject({ status: "pending", kind: "request_confirmation" }); - await expect(interactionsSvc.acceptInteraction({ - id: issueId, - companyId, - goalId, - projectId: null, - }, created.id, {}, { + await expect(interactionsSvc.acceptInteraction(acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board", })).resolves.toMatchObject({ interaction: { status: "accepted" }, @@ -4196,12 +3877,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const revisionId = randomUUID(); const nextRevisionId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ id: goalId, @@ -4210,14 +3886,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { level: "task", status: "active", }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); + await db.insert(issues).values(parentIssueRow(issueId, companyId, goalId)); await db.insert(documents).values({ id: documentId, companyId, @@ -4357,12 +4026,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { const foreignRunId = randomUUID(); const kind = options?.kind ?? "request_confirmation"; - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await db.insert(companies).values(interactionCompanyRow(companyId)); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(projects).values({ id: projectId, @@ -4379,17 +4043,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { visibility: "default", isPrimary: true, }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await db.insert(agents).values(activeCodexAgentRow(agentId, companyId, "CodexCoder", "engineer")); const sourceRunStatus = options?.sourceRunStatus ?? "succeeded"; const sourceRunTerminal = sourceRunStatus !== "running"; await db.insert(heartbeatRuns).values([ @@ -4596,7 +4250,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4624,7 +4278,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { await expect( interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4654,7 +4308,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4692,7 +4346,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4722,7 +4376,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4752,7 +4406,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { await expect( interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4780,7 +4434,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, {}, { userId: "local-board" }, @@ -4815,7 +4469,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-b"] }, { userId: "local-board" }, @@ -4846,7 +4500,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { await expect( interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-a"] }, { userId: "local-board" }, @@ -4869,7 +4523,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-a"] }, { userId: "local-board" }, @@ -4896,7 +4550,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), interactionId, { selectedOptionIds: ["file-a"] }, { userId: "local-board" }, @@ -4946,7 +4600,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); const accepted = await interactionsSvc.acceptInteraction( - { id: issueId, companyId, goalId, projectId: null }, + acceptIssueScope(issueId, companyId, goalId), created.id, {}, { userId: "local-board" }, diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 5bf63139673..8c566598e82 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -276,8 +276,6 @@ describe("issue update comment wakeups", () => { mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null); mockIssueService.listReviewAttention.mockResolvedValue(new Map()); - // A fresh route graph is fixture preparation, not request execution. Keep - // per-case module resets while charging the cold transform to hookTimeout. routeFixture = await loadRouteFixture(); }); diff --git a/server/src/__tests__/redact-sensitive.test.ts b/server/src/__tests__/redact-sensitive.test.ts index b0467a7fb96..151c9212a5d 100644 --- a/server/src/__tests__/redact-sensitive.test.ts +++ b/server/src/__tests__/redact-sensitive.test.ts @@ -6,6 +6,11 @@ import { stripSecretBearingUrlParts, } from "../middleware/redact-sensitive.js"; +it("redacts Slack manager grants and sealed OAuth handoffs", () => { + const result = redactSensitive({ managerToken: "manager-canary", refreshToken: "refresh-canary", sealed: { ct: "sealed-canary" }, handoff: { token: "handoff-canary" }, claim_id: "claim-canary" }); + expect(JSON.stringify(result)).not.toContain("canary"); +}); + describe("redactSensitive", () => { it("redacts a plaintext password field on a sign-in body", () => { const body = { diff --git a/server/src/__tests__/remove-test-home.test.ts b/server/src/__tests__/remove-test-home.test.ts index 0b4a5baf82b..4436a7711d1 100644 --- a/server/src/__tests__/remove-test-home.test.ts +++ b/server/src/__tests__/remove-test-home.test.ts @@ -63,7 +63,6 @@ describe("removeTestHome", () => { const home = makeHome(); fs.mkdirSync(path.join(home, "instances", "default"), { recursive: true }); fs.writeFileSync(path.join(home, "instances", "default", "paperclip.json"), "{}\n"); - // An unrelated ENOTEMPTY must not cause permission changes or be swallowed. const error = Object.assign(new Error("unrelated nonempty directory"), { code: "ENOTEMPTY" }); const removal = vi.spyOn(fs, "rmSync").mockImplementation(() => { throw error; }); const permissionChange = vi.spyOn(fs, "chmodSync"); diff --git a/server/src/__tests__/remove-test-home.ts b/server/src/__tests__/remove-test-home.ts index 1f2260f9bb2..39cbad6087b 100644 --- a/server/src/__tests__/remove-test-home.ts +++ b/server/src/__tests__/remove-test-home.ts @@ -9,8 +9,7 @@ import path from "node:path"; * in them read-only (`makeDirectoriesReadOnly` in * services/native-runtime/runtime-context.ts), and a run that is still * finishing when the worker exits can leave such a bundle under the worker's - * home. Removing a file inside a read-only directory fails with `EACCES` - * or, on macOS, the recursive removal can report `ENOTEMPTY` instead. + * home. Removing a file inside a read-only directory fails with `EACCES`, * Vitest reports it as an unhandled error, and the shard fails after every * test passed. So on a permission error every directory in the tree is made * writable first and the removal is tried once more. @@ -23,8 +22,6 @@ export function removeTestHome(home: string): void { const permissionError = isPermissionError(error); if (!permissionError && (error as NodeJS.ErrnoException | null)?.code !== "ENOTEMPTY") throw error; const restored = makeDirectoriesWritable(home); - // ENOTEMPTY also has unrelated causes; retry only after restoring an - // actual read-only directory, and still surface a failed second removal. if (!permissionError && !restored) throw error; fs.rmSync(home, options); } @@ -51,7 +48,6 @@ function makeDirectoriesWritable(directory: string): boolean { fs.chmodSync(directory, stat.mode | 0o700); restored = true; } catch { - // A directory that cannot be changed still fails the removal below, loudly. } } let entries: fs.Dirent[]; diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 186af5a993e..e570ec2a6ee 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -184,6 +184,46 @@ describeEmbeddedPostgres("secretService", () => { rmSync(secretsTmpDir, { recursive: true, force: true }); }); + function awsVaultInput(): Parameters["createProviderConfig"]>[1] { + return { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { region: "us-east-1", namespace: "prod-use1" }, + }; + } + + function preparedAwsVersion(externalRef: string, versionId: string, valueSha256: string, fingerprintSha256: string): Awaited> { + return { + material: { + scheme: "aws_secrets_manager_v1", + secretId: externalRef, + versionId, + source: "managed", + }, + valueSha256, + fingerprintSha256, + externalRef, + providerVersionRef: versionId, + }; + } + + function managedAwsVersionRow(secretId: string, externalRef: string): typeof companySecretVersions.$inferInsert { + return { + secretId, + version: 1, + material: { + scheme: "aws_secrets_manager_v1", + secretId: externalRef, + versionId: "aws-version-1", + source: "managed", + }, + valueSha256: "value-sha-1", + fingerprintSha256: "fingerprint-sha-1", + providerVersionRef: "aws-version-1", + status: "current", + }; + } + async function seedCompany(name = "Acme") { const companyId = randomUUID(); await db.insert(companies).values({ @@ -925,52 +965,62 @@ describeEmbeddedPostgres("secretService", () => { // provider is `local_encrypted`. const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret", - providerVersionRef: "aws-version-1", - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + const createSecretSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); + + const originalProviderWrite = createSecretSpy.getMockImplementation()!; + const uncontendedEntryDurationMs = await measureUncontendedEntryDurationMs( + createSecretSpy, + originalProviderWrite, + () => svc.create(companyId, { + name: `aws-baseline-${randomUUID()}`, + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + value: "baseline-runtime-secret", + }), + ); const events: string[] = []; + const cleanupEntered = deferred(); + const providerEntered = deferred(); + createSecretSpy.mockImplementationOnce(async (input) => { + events.push("aws-provider-enter"); + providerEntered.resolve(); + return originalProviderWrite(input); + }); let releaseCleanup!: () => void; const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve; }); const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => { events.push("cleanup-enter"); + cleanupEntered.resolve(); await cleanupGate; events.push("cleanup-exit"); }); - // Give the cleanup a chance to acquire the lock before the AWS create - // starts racing for the same lock. - await new Promise((resolve) => setTimeout(resolve, 20)); - const createCall = svc.create(companyId, { - name: `aws-secret-${randomUUID()}`, - provider: "aws_secrets_manager", - providerConfigId: awsVault.id, - value: "runtime-secret", - }); - // The AWS create must stay queued behind the held lock, the same way a - // `local_encrypted` create does. - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(events).toEqual(["cleanup-enter"]); - - releaseCleanup(); - await cleanupCall; - const created = await createCall; - expect(events).toEqual(["cleanup-enter", "cleanup-exit"]); + let createCall: ReturnType | undefined; + let outcomes: PromiseSettledResult[] = []; + try { + await awaitEntryOrOperationFailure(cleanupEntered.promise, cleanupCall, "cleanupCall"); + createCall = svc.create(companyId, { + name: `aws-secret-${randomUUID()}`, + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + value: "runtime-secret", + }); + void createCall.catch(() => {}); + await waitEntryDetectionWindow(uncontendedEntryDurationMs, providerEntered.promise); + expect(events).toEqual(["cleanup-enter"]); + expect(createSecretSpy).toHaveBeenCalledTimes(ENTRY_DETECTION_BASELINE_SAMPLE_COUNT); + } finally { + releaseCleanup(); + outcomes = await Promise.allSettled([cleanupCall, createCall]); + } + for (const outcome of outcomes) { + if (outcome.status === "rejected") throw outcome.reason; + } + const created = await createCall!; + expect(events).toEqual(["cleanup-enter", "cleanup-exit", "aws-provider-enter"]); expect(created.status).toBe("active"); }); @@ -980,35 +1030,9 @@ describeEmbeddedPostgres("secretService", () => { // `local_encrypted` rotate. const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", - providerVersionRef: "aws-version-1", - }); - vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", - versionId: "aws-version-2", - source: "managed", - }, - valueSha256: "value-sha-2", - fingerprintSha256: "fingerprint-sha-2", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", - providerVersionRef: "aws-version-2", - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); + const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate", "aws-version-2", "value-sha-2", "fingerprint-sha-2")); const existing = await svc.create(companyId, { name: `aws-secret-rotate-${randomUUID()}`, provider: "aws_secrets_manager", @@ -1016,25 +1040,55 @@ describeEmbeddedPostgres("secretService", () => { value: "runtime-secret", }); + const baselineSecret = await svc.create(companyId, { + name: `aws-baseline-${randomUUID()}`, + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + value: "baseline-runtime-secret", + }); + const originalProviderWrite = createVersionSpy.getMockImplementation()!; + const uncontendedEntryDurationMs = await measureUncontendedEntryDurationMs( + createVersionSpy, + originalProviderWrite, + () => svc.rotate(baselineSecret.id, { value: "baseline-rotated-runtime-secret" }), + ); + const events: string[] = []; + const cleanupEntered = deferred(); + const providerEntered = deferred(); + createVersionSpy.mockImplementationOnce(async (input) => { + events.push("aws-provider-enter"); + providerEntered.resolve(); + return originalProviderWrite(input); + }); let releaseCleanup!: () => void; const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve; }); const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => { events.push("cleanup-enter"); + cleanupEntered.resolve(); await cleanupGate; events.push("cleanup-exit"); }); - await new Promise((resolve) => setTimeout(resolve, 20)); - const rotateCall = svc.rotate(existing.id, { value: "rotated-runtime-secret" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(events).toEqual(["cleanup-enter"]); - - releaseCleanup(); - await cleanupCall; - const rotated = await rotateCall; - expect(events).toEqual(["cleanup-enter", "cleanup-exit"]); + let rotateCall: ReturnType | undefined; + let outcomes: PromiseSettledResult[] = []; + try { + await awaitEntryOrOperationFailure(cleanupEntered.promise, cleanupCall, "cleanupCall"); + rotateCall = svc.rotate(existing.id, { value: "rotated-runtime-secret" }); + void rotateCall.catch(() => {}); + await waitEntryDetectionWindow(uncontendedEntryDurationMs, providerEntered.promise); + expect(events).toEqual(["cleanup-enter"]); + expect(createVersionSpy).toHaveBeenCalledTimes(ENTRY_DETECTION_BASELINE_SAMPLE_COUNT); + } finally { + releaseCleanup(); + outcomes = await Promise.allSettled([cleanupCall, rotateCall]); + } + for (const outcome of outcomes) { + if (outcome.status === "rejected") throw outcome.reason; + } + const rotated = await rotateCall!; + expect(events).toEqual(["cleanup-enter", "cleanup-exit", "aws-provider-enter"]); expect(rotated.latestVersion).toBe(2); }); @@ -2008,11 +2062,7 @@ describeEmbeddedPostgres("secretService", () => { const companyId = await seedCompany(); await seedCompanyMember(companyId, "user-1", "owner"); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const definition = await svc.createUserSecretDefinition(companyId, { key: "github_token", name: "GitHub token", @@ -2070,11 +2120,7 @@ describeEmbeddedPostgres("secretService", () => { const companyId = await seedCompany(); await seedCompanyMember(companyId, "user-1", "owner"); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const definition = await svc.createUserSecretDefinition(companyId, { key: "github_token", name: "GitHub token", @@ -2083,18 +2129,7 @@ describeEmbeddedPostgres("secretService", () => { }); const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user/github-token"; - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef, - providerVersionRef: "aws-version-1", - }); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion(externalRef, "aws-version-1", "value-sha-1", "fingerprint-sha-1")); vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db activate failed")); vi.spyOn(db, "delete").mockImplementationOnce(() => { @@ -2164,11 +2199,7 @@ describeEmbeddedPostgres("secretService", () => { await seedCompanyMember(companyId, "user-1", "owner"); await seedCompanyMember(companyId, "user-2", "member"); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const definition = await svc.createUserSecretDefinition(companyId, { key: "github_token", name: "GitHub token", @@ -2180,18 +2211,7 @@ describeEmbeddedPostgres("secretService", () => { nextVersion += 1; const externalRef = `arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/${input.context.secretKey}`; - return { - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: `aws-version-${nextVersion}`, - source: "managed", - }, - valueSha256: `value-sha-${nextVersion}`, - fingerprintSha256: `fingerprint-sha-${nextVersion}`, - externalRef, - providerVersionRef: `aws-version-${nextVersion}`, - }; + return preparedAwsVersion(externalRef, `aws-version-${nextVersion}`, `value-sha-${nextVersion}`, `fingerprint-sha-${nextVersion}`); }); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); const userOneSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", { @@ -2245,29 +2265,14 @@ describeEmbeddedPostgres("secretService", () => { const companyId = await seedCompany(); await seedCompanyMember(companyId, "user-1", "owner"); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const definition = await svc.createUserSecretDefinition(companyId, { key: "github_token", name: "GitHub token", provider: "aws_secrets_manager", providerConfigId: awsVault.id, }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret", - providerVersionRef: "aws-version-1", - }); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); const userSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", { definitionId: definition.id, @@ -2622,11 +2627,7 @@ describeEmbeddedPostgres("secretService", () => { it("stores external references without requiring or persisting secret values", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const secret = await svc.create(companyId, { name: `external-${randomUUID()}`, @@ -2727,11 +2728,7 @@ describeEmbeddedPostgres("secretService", () => { it("removes provider vault config locally without deleting remote AWS secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const vault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const vault = await svc.createProviderConfig(companyId, awsVaultInput()); const secret = await svc.create(companyId, { name: `external-${randomUUID()}`, provider: "aws_secrets_manager", @@ -2782,11 +2779,7 @@ describeEmbeddedPostgres("secretService", () => { it("rejects bindings and env refs to soft-deleted external reference secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const deleted = await svc.create(companyId, { name: "Deleted external", key: "deleted-external", @@ -2816,11 +2809,7 @@ describeEmbeddedPostgres("secretService", () => { it("rejects updates to already soft-deleted external reference secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const deleted = await svc.create(companyId, { name: "Deleted patch target", key: "deleted-patch-target", @@ -2839,11 +2828,7 @@ describeEmbeddedPostgres("secretService", () => { it("allows re-importing a remote secret after the prior external reference is soft-deleted", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/reimportable"; const deleted = await svc.create(companyId, { @@ -2892,11 +2877,7 @@ describeEmbeddedPostgres("secretService", () => { it("ignores soft-deleted name and key conflicts during remote import", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const deleted = await svc.create(companyId, { name: "Deleted external", key: "deleted-external", @@ -2992,30 +2973,8 @@ describeEmbeddedPostgres("secretService", () => { }, }); - const createSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", - providerVersionRef: "aws-version-1", - }); - const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", - versionId: "aws-version-2", - source: "managed", - }, - valueSha256: "value-sha-2", - fingerprintSha256: "fingerprint-sha-2", - externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", - providerVersionRef: "aws-version-2", - }); + const createSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); + const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", "aws-version-2", "value-sha-2", "fingerprint-sha-2")); const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion").mockResolvedValue("resolved-secret"); const secret = await svc.create(companyId, { @@ -3048,25 +3007,8 @@ describeEmbeddedPostgres("secretService", () => { it("cleans up managed provider secrets when create persistence fails", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - const prepared = { - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback", - providerVersionRef: "aws-version-1", - }; + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback", "aws-version-1", "value-sha-1", "fingerprint-sha-1"); vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db insert failed")); @@ -3103,25 +3045,8 @@ describeEmbeddedPostgres("secretService", () => { it("keeps a local cleanup handle when create rollback cleanup fails", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - const prepared = { - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle", - providerVersionRef: "aws-version-1", - }; + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle", "aws-version-1", "value-sha-1", "fingerprint-sha-1"); vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared); vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValue( new Error("cleanup failed"), @@ -3171,25 +3096,8 @@ describeEmbeddedPostgres("secretService", () => { it("reports managed secret persistence rollback failures when local cleanup cannot remove the reserved row", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - const prepared = { - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-local-cleanup", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-local-cleanup", - providerVersionRef: "aws-version-1", - }; + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-local-cleanup", "aws-version-1", "value-sha-1", "fingerprint-sha-1"); vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared); vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db activate failed")); @@ -3227,25 +3135,8 @@ describeEmbeddedPostgres("secretService", () => { it("archives managed provider versions when rotate persistence fails", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", - providerVersionRef: "aws-version-1", - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); const secret = await svc.create(companyId, { name: "Rotate Rollback", key: "rotate-rollback", @@ -3253,20 +3144,7 @@ describeEmbeddedPostgres("secretService", () => { providerConfigId: awsVault.id, value: "runtime-secret", }); - const prepared = { - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", - versionId: "aws-version-2", - source: "managed", - }, - valueSha256: "value-sha-2", - fingerprintSha256: "fingerprint-sha-2", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", - providerVersionRef: "aws-version-2", - }; + const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback", "aws-version-2", "value-sha-2", "fingerprint-sha-2"); vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(prepared); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db rotate failed")); @@ -3292,25 +3170,8 @@ describeEmbeddedPostgres("secretService", () => { it("keeps a disabled version cleanup handle when rotate rollback cleanup fails", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", - providerVersionRef: "aws-version-1", - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); const secret = await svc.create(companyId, { name: "Rotate Cleanup Handle", key: "rotate-cleanup-handle", @@ -3318,20 +3179,7 @@ describeEmbeddedPostgres("secretService", () => { providerConfigId: awsVault.id, value: "runtime-secret", }); - const prepared = { - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", - versionId: "aws-version-2", - source: "managed", - }, - valueSha256: "value-sha-2", - fingerprintSha256: "fingerprint-sha-2", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", - providerVersionRef: "aws-version-2", - }; + const prepared = preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle", "aws-version-2", "value-sha-2", "fingerprint-sha-2"); vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(prepared); vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValue( new Error("cleanup failed"), @@ -3372,20 +3220,7 @@ describeEmbeddedPostgres("secretService", () => { displayName: "AWS secondary", config: { region: "us-west-2", namespace: "prod-usw2" }, }); - vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ - material: { - scheme: "aws_secrets_manager_v1", - secretId: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign", - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - externalRef: - "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign", - providerVersionRef: "aws-version-1", - }); + vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(preparedAwsVersion("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign", "aws-version-1", "value-sha-1", "fingerprint-sha-1")); const secret = await svc.create(companyId, { name: "Vault Reassign", key: "vault-reassign", @@ -3426,11 +3261,7 @@ describeEmbeddedPostgres("secretService", () => { it("previews AWS remote import candidates with duplicate and collision enrichment", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const duplicate = await svc.create(companyId, { name: "Existing duplicate", provider: "aws_secrets_manager", @@ -3504,11 +3335,7 @@ describeEmbeddedPostgres("secretService", () => { it("sanitizes AWS remote import preview provider errors before crossing the service boundary", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const rawProviderMessage = "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets"; @@ -3542,11 +3369,7 @@ describeEmbeddedPostgres("secretService", () => { it("sanitizes AWS managed secret create failures and removes the reserved row", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const rawProviderMessage = "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:CreateSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1"; @@ -3600,11 +3423,7 @@ describeEmbeddedPostgres("secretService", () => { it("reports rollback failures when AWS managed secret create cleanup cannot remove the reserved row", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); vi.spyOn(awsSecretsManagerProvider, "createSecret").mockRejectedValueOnce( new SecretProviderClientError({ @@ -3777,11 +3596,7 @@ describeEmbeddedPostgres("secretService", () => { it("writes external reference rotations through the provider when a value is given", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/neon-admin"; const secret = await svc.create(companyId, { name: `external-${randomUUID()}`, @@ -3832,11 +3647,7 @@ describeEmbeddedPostgres("secretService", () => { it("restores the provider current version when external value rotation persistence fails", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/rollback"; const secret = await svc.create(companyId, { name: "External rollback", @@ -3885,11 +3696,7 @@ describeEmbeddedPostgres("secretService", () => { it("rejects external value rotations that also retarget or pin versions", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const secret = await svc.create(companyId, { name: `external-${randomUUID()}`, provider: "aws_secrets_manager", @@ -3927,11 +3734,7 @@ describeEmbeddedPostgres("secretService", () => { it("imports AWS remote references row-by-row without fetching plaintext", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const duplicate = await svc.create(companyId, { name: "Existing duplicate", provider: "aws_secrets_manager", @@ -4000,11 +3803,7 @@ describeEmbeddedPostgres("secretService", () => { it("sanitizes AWS remote import row provider errors", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const rawProviderMessage = "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:DescribeSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai"; vi.spyOn(awsSecretsManagerProvider, "linkExternalSecret").mockRejectedValueOnce( @@ -4047,11 +3846,7 @@ describeEmbeddedPostgres("secretService", () => { it("rejects Paperclip-managed AWS namespace refs during preview and import commit", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({ secrets: [ @@ -4112,11 +3907,7 @@ describeEmbeddedPostgres("secretService", () => { it("skips duplicate AWS remote imports for the same provider vault and canonical ref", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const first = await svc.importRemoteSecrets(companyId, { providerConfigId: awsVault.id, @@ -4200,11 +3991,7 @@ describeEmbeddedPostgres("secretService", () => { it("rejects generic update retargeting for external reference secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); - const awsVault = await svc.createProviderConfig(companyId, { - provider: "aws_secrets_manager", - displayName: "AWS production", - config: { region: "us-east-1", namespace: "prod-use1" }, - }); + const awsVault = await svc.createProviderConfig(companyId, awsVaultInput()); const secret = await svc.create(companyId, { name: `external-${randomUUID()}`, provider: "aws_secrets_manager", @@ -4263,20 +4050,7 @@ describeEmbeddedPostgres("secretService", () => { .returning() .then((rows) => rows[0]); - await db.insert(companySecretVersions).values({ - secretId: secret.id, - version: 1, - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - providerVersionRef: "aws-version-1", - status: "current", - }); + await db.insert(companySecretVersions).values(managedAwsVersionRow(secret.id, externalRef)); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); @@ -4329,20 +4103,7 @@ describeEmbeddedPostgres("secretService", () => { .returning() .then((rows) => rows[0]); - await db.insert(companySecretVersions).values({ - secretId: secret.id, - version: 1, - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - providerVersionRef: "aws-version-1", - status: "current", - }); + await db.insert(companySecretVersions).values(managedAwsVersionRow(secret.id, externalRef)); vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValueOnce( new Error("provider delete failed"), ); @@ -4386,20 +4147,7 @@ describeEmbeddedPostgres("secretService", () => { status: "deleted", deletedAt: new Date(), }); - await db.insert(companySecretVersions).values({ - secretId, - version: 1, - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - providerVersionRef: "aws-version-1", - status: "current", - }); + await db.insert(companySecretVersions).values(managedAwsVersionRow(secretId, externalRef)); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValueOnce( new SecretProviderClientError({ code: "not_found", @@ -4449,20 +4197,7 @@ describeEmbeddedPostgres("secretService", () => { .returning() .then((rows) => rows[0]); - await db.insert(companySecretVersions).values({ - secretId: secret.id, - version: 1, - material: { - scheme: "aws_secrets_manager_v1", - secretId: externalRef, - versionId: "aws-version-1", - source: "managed", - }, - valueSha256: "value-sha-1", - fingerprintSha256: "fingerprint-sha-1", - providerVersionRef: "aws-version-1", - status: "current", - }); + await db.insert(companySecretVersions).values(managedAwsVersionRow(secret.id, externalRef)); await svc.disableProviderConfig(vault.id); const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); diff --git a/server/src/__tests__/slack-development-manager.test.ts b/server/src/__tests__/slack-development-manager.test.ts new file mode 100644 index 00000000000..314e087843c --- /dev/null +++ b/server/src/__tests__/slack-development-manager.test.ts @@ -0,0 +1,73 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; +import { developmentSlackManager } from "../services/connectors/slack/setup/development-manager.js"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(path => rmSync(path, { recursive: true, force: true }))); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "slack-development-")); roots.push(root); + const path = join(root, "manager.json"); + writeFileSync(path, JSON.stringify({ appId: "A123", clientId: "123.456", clientSecret: "client-secret-canary" }), { mode: 0o600 }); + return { path, env: { NODE_ENV: "development", PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "true", PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG: path } }; +} +const origin = () => "https://local-test.trycloudflare.com"; +const identity = { subject: "actor", companyId: "company", profile: "slack.manager" as const }; +const user = { id: "U123", token_type: "user", access_token: "access-canary", refresh_token: "refresh-canary", expires_in: 43200, scope: SLACK_MANAGER_SCOPES.join(",") }; +const response = (value: unknown) => new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } }); + +describe("local managed Slack qualification", () => { + it("requires explicit development opt-in and refuses production", () => { + const { env } = fixture(); + expect(developmentSlackManager(origin, {})).toBeNull(); + expect(developmentSlackManager(origin, { ...env, NODE_ENV: "production" })).toBeNull(); + expect(developmentSlackManager(origin, { ...env, PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "false" })).toBeNull(); + }); + it("rejects readable secrets and invalid HTTPS origins", async () => { + const { env, path } = fixture(); chmodSync(path, 0o644); + expect(() => developmentSlackManager(origin, env)).toThrow(); + chmodSync(path, 0o600); + await expect(developmentSlackManager(() => "http://local.test", env)!.getSlackManagerAppId()).rejects.toThrow(); + }); + it("builds a scoped authorization URL without exposing the client secret", async () => { + const { env } = fixture(); + const client = developmentSlackManager(origin, env)!; + const result = await client.startAuthorization({ ...identity, returnUri: `${origin()}/api/chat-slack/managed/oauth/callback`, returnState: "bound-state" }); + const url = new URL(result.authorizationUrl); + expect(url.origin).toBe("https://slack.com"); + expect(url.searchParams.get("state")).toBe("bound-state"); + expect(url.searchParams.get("user_scope")).toBe(SLACK_MANAGER_SCOPES.join(",")); + expect(JSON.stringify(result)).not.toContain("client-secret-canary"); + await expect(client.startAuthorization({ ...identity, returnUri: "https://other.test/callback", returnState: "bound-state" })).rejects.toThrow(); + }); + it("exchanges the code against the exact callback and preserves actor/workspace identity", async () => { + const { env } = fixture(); + const fetcher = vi.fn().mockResolvedValue(response({ ok: true, app_id: "A123", team: { id: "T123", name: "Paperclip" }, authed_user: user })); + const result = await developmentSlackManager(origin, env, fetcher)!.claim({ ...identity, claimId: "code-canary", redemptionId: "once" }); + expect(result.slackManager).toEqual({ appId: "A123", workspaceId: "T123", workspaceName: "Paperclip", userId: "U123" }); + expect(result.subject).toBe("actor"); expect(result.companyId).toBe("company"); + const [url, request] = fetcher.mock.calls[0]; + expect(url).toBe("https://slack.com/api/oauth.v2.access"); + expect(request.redirect).toBe("error"); + expect(request.body.get("redirect_uri")).toBe(`${origin()}/api/chat-slack/managed/oauth/callback`); + expect(request.body.get("code")).toBe("code-canary"); + }); + it.each(["wrong-app", "missing-scope", "provider-echo"])("rejects %s without echoing secrets", async reason => { + const { env } = fixture(); + const value = reason === "provider-echo" ? { ok: false, error: "client-secret-canary code-canary" } : { ok: true, app_id: reason === "wrong-app" ? "AOTHER" : "A123", team: { id: "T123", name: "Paperclip" }, authed_user: { ...user, scope: reason === "missing-scope" ? "" : user.scope } }; + const client = developmentSlackManager(origin, env, vi.fn().mockResolvedValue(response(value)))!; + const error = await client.claim({ ...identity, claimId: "code-canary", redemptionId: "once" }).catch(error => error); + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toMatch(/canary/); + }); + it("rotates refresh credentials and verifies revocation", async () => { + const { env } = fixture(); + const fetcher = vi.fn().mockResolvedValueOnce(response({ ok: true, ...user })).mockResolvedValueOnce(response({ ok: false, error: "invalid_auth" })); + const client = developmentSlackManager(origin, env, fetcher)!; + expect((await client.refresh({ ...identity, refreshToken: "old-refresh" })).refreshToken).toBe("refresh-canary"); + expect(fetcher.mock.calls[0][1].body.get("grant_type")).toBe("refresh_token"); + await expect(client.revoke({ ...identity, token: "access-canary" })).rejects.toThrow(); + }); +}); diff --git a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts index aebe8a8df40..01810075f33 100644 --- a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts +++ b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts @@ -11,8 +11,6 @@ import { } from "./helpers/embedded-postgres.js"; import { teamsCatalogService } from "../services/teams-catalog.js"; -// Bind the real creation resolver to the release target under test so the -// catalog contract is stable across the machines running this fixture. vi.mock("../services/agent-runner-selection.js", async importOriginal => { const actual = await importOriginal(); return { ...actual, resolveNewAgentRunnerForCompany: async (_db: unknown, _companyId: string, input: Parameters[2]) => diff --git a/server/src/__tests__/tool-access-policy-service.test.ts b/server/src/__tests__/tool-access-policy-service.test.ts index b573c4b51d0..c9498279d68 100644 --- a/server/src/__tests__/tool-access-policy-service.test.ts +++ b/server/src/__tests__/tool-access-policy-service.test.ts @@ -149,6 +149,56 @@ async function createApprovedToolAction(input: { return { decisionInput, invocation: recorded.invocation, actionRequest: updated }; } +type IdRef = { id: string }; + +function sendEmailGrantRow(company: IdRef, agent: IdRef): typeof principalPermissionGrants.$inferInsert { + return { + companyId: company.id, + principalType: "agent", + principalId: agent.id, + permissionKey: "tools:use", + scope: { toolName: "send_email" }, + }; +} + +function agentProfileBindingRow(company: IdRef, profile: IdRef, agent: IdRef): typeof toolProfileBindings.$inferInsert { + return { + companyId: company.id, + profileId: profile.id, + targetType: "agent", + targetId: agent.id, + }; +} + +function sendEmailDecisionInput(company: IdRef, agent: IdRef, connection: IdRef, catalogEntry: IdRef): Parameters["decide"]>[0] { + return { + companyId: company.id, + actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, + request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, + }; +} + +function reviewSendPolicyRow(company: IdRef, name: string): typeof toolPolicies.$inferInsert { + return { + companyId: company.id, + name: name, + policyType: "require_approval", + selectors: { toolName: "send_email" }, + }; +} + +function executedActionInput(db: ReturnType, company: IdRef, agent: IdRef, connection: IdRef, catalogEntry: IdRef, args: Record): Parameters[0] { + return { + db, + companyId: company.id, + agentId: agent.id, + connectionId: connection.id, + catalogEntryId: catalogEntry.id, + argumentsValue: args, + status: "executed", + }; +} + describeEmbeddedPostgres("tool access policy service", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; @@ -190,11 +240,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - const result = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(result).toMatchObject({ allowed: false, @@ -213,12 +259,7 @@ describeEmbeddedPostgres("tool access policy service", () => { name: "Write tools", defaultAction: "deny", }).returning().then((rows) => rows[0]!); - await db.insert(toolProfileBindings).values({ - companyId: company.id, - profileId: profile.id, - targetType: "agent", - targetId: agent.id, - }); + await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent)); await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id, @@ -227,11 +268,7 @@ describeEmbeddedPostgres("tool access policy service", () => { toolName: "send_email", }); - const result = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(result).toMatchObject({ allowed: true, @@ -468,11 +505,7 @@ describeEmbeddedPostgres("tool access policy service", () => { ]); for (const agent of [draftAgent, archivedAgent]) { - const result = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(result).toMatchObject({ allowed: false, decision: "deny", @@ -486,13 +519,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { application, connection, catalogEntry } = await createTool(db, company.id); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: agent.id, - permissionKey: "tools:use", - scope: { toolName: "send_email" }, - }); + await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent)); const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id }, @@ -615,12 +642,7 @@ describeEmbeddedPostgres("tool access policy service", () => { name: "Write tools", defaultAction: "allow", }).returning().then((rows) => rows[0]!); - await db.insert(toolProfileBindings).values({ - companyId: company.id, - profileId: profile.id, - targetType: "agent", - targetId: agent.id, - }); + await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent)); const wildcardPolicy = await db.insert(toolPolicies).values({ companyId: company.id, name: "Review wildcard-looking sends", @@ -825,13 +847,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection } = await createTool(db, company.id); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: agent.id, - permissionKey: "tools:use", - scope: { toolName: "send_email" }, - }); + await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent)); const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id }, @@ -857,13 +873,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection } = await createTool(db, company.id); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: agent.id, - permissionKey: "tools:use", - scope: { toolName: "send_email" }, - }); + await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent)); const input = { companyId: company.id, actor: { actorType: "agent" as const, actorId: agent.id, agentId: agent.id }, @@ -889,13 +899,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection } = await createTool(db, company.id); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: agent.id, - permissionKey: "tools:use", - scope: { toolName: "send_email" }, - }); + await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent)); await db.insert(toolPolicies).values({ companyId: company.id, name: "One send per minute", @@ -925,13 +929,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection } = await createTool(db, company.id); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: agent.id, - permissionKey: "tools:use", - scope: { toolName: "send_email" }, - }); + await db.insert(principalPermissionGrants).values(sendEmailGrantRow(company, agent)); await db.insert(toolPolicies).values({ companyId: company.id, name: "One concurrent send per minute", @@ -1105,12 +1103,7 @@ describeEmbeddedPostgres("tool access policy service", () => { name: "Fallback allow", defaultAction: "allow", }).returning().then((rows) => rows[0]!); - await db.insert(toolProfileBindings).values({ - companyId: company.id, - profileId: profile.id, - targetType: "agent", - targetId: agent.id, - }); + await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent)); const [redactPolicy] = await db.insert(toolPolicies).values({ companyId: company.id, name: "Legacy redact", @@ -1128,17 +1121,9 @@ describeEmbeddedPostgres("tool access policy service", () => { priority: 2, }).returning(); - const redactDecision = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const redactDecision = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); await db.update(toolPolicies).set({ enabled: false }).where(eq(toolPolicies.id, redactPolicy!.id)); - const validateDecision = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const validateDecision = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(redactDecision).toMatchObject({ allowed: false, @@ -1164,12 +1149,7 @@ describeEmbeddedPostgres("tool access policy service", () => { name: "Fallback allow", defaultAction: "allow", }).returning().then((rows) => rows[0]!); - await db.insert(toolProfileBindings).values({ - companyId: company.id, - profileId: profile.id, - targetType: "agent", - targetId: agent.id, - }); + await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent)); const [policy] = await db.insert(toolPolicies).values({ companyId: company.id, name: "Legacy conditional allow", @@ -1179,11 +1159,7 @@ describeEmbeddedPostgres("tool access policy service", () => { priority: 1, }).returning(); - const result = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(result).toMatchObject({ allowed: false, @@ -1203,12 +1179,7 @@ describeEmbeddedPostgres("tool access policy service", () => { name: "Fallback allow", defaultAction: "allow", }).returning().then((rows) => rows[0]!); - await db.insert(toolProfileBindings).values({ - companyId: company.id, - profileId: profile.id, - targetType: "agent", - targetId: agent.id, - }); + await db.insert(toolProfileBindings).values(agentProfileBindingRow(company, profile, agent)); const [policy] = await db.insert(toolPolicies).values({ companyId: company.id, name: "Broken rate limit", @@ -1218,11 +1189,7 @@ describeEmbeddedPostgres("tool access policy service", () => { priority: 1, }).returning(); - const result = await toolAccessPolicyService(db).decide({ - companyId: company.id, - actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, - request: { connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email" }, - }); + const result = await toolAccessPolicyService(db).decide(sendEmailDecisionInput(company, agent, connection, catalogEntry)); expect(result).toMatchObject({ allowed: false, @@ -1311,12 +1278,7 @@ describeEmbeddedPostgres("tool access policy service", () => { const agentA = await createAgent(db, company.id); const agentB = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review scoped sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review scoped sends")); const args = { to: "ops@example.com", body: "same reviewed payload" }; await createApprovedToolAction({ db, @@ -1348,35 +1310,14 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review versioned sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review versioned sends")); const args = { to: "ops@example.com", body: "same payload after tool change" }; - await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await db .update(toolCatalogEntries) .set({ versionHash: randomUUID(), schemaHash: randomUUID(), updatedAt: new Date() }) .where(eq(toolCatalogEntries.id, catalogEntry.id)); - const currentVersionAction = await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + const currentVersionAction = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({ companyId: company.id, @@ -1389,31 +1330,10 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review scoped sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review scoped sends")); const args = { to: "ops@example.com", body: "same reviewed payload" }; - const first = await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); - await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); + await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({ companyId: company.id, @@ -1435,31 +1355,10 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review exact payload sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review exact payload sends")); const args = { to: "ops@example.com", body: "same reviewed payload" }; - const first = await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); - await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); + await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await expect(toolAccessPolicyService(db).createTrustRuleFromActionRequest({ companyId: company.id, @@ -1475,22 +1374,9 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review changed sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review changed sends")); const args = { to: "ops@example.com", body: "repeatable" }; - const first = await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await createApprovedToolAction({ db, companyId: company.id, @@ -1523,22 +1409,9 @@ describeEmbeddedPostgres("tool access policy service", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection, catalogEntry } = await createTool(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review revocable sends", - policyType: "require_approval", - selectors: { toolName: "send_email" }, - }); + await db.insert(toolPolicies).values(reviewSendPolicyRow(company, "Review revocable sends")); const args = { to: "ops@example.com", body: "revocable" }; - const first = await createApprovedToolAction({ - db, - companyId: company.id, - agentId: agent.id, - connectionId: connection.id, - catalogEntryId: catalogEntry.id, - argumentsValue: args, - status: "executed", - }); + const first = await createApprovedToolAction(executedActionInput(db, company, agent, connection, catalogEntry, args)); await createApprovedToolAction({ db, companyId: company.id, diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 09cdeecdfb4..16dae1966e6 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -816,6 +816,90 @@ async function createRemoteToolFixture( }; } +type IdRef = { id: string }; + +function catalogResponse(tools: unknown[]) { + return { jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools } }; +} + +function readTool(name: string, description: string) { + return { + name: name, + description: description, + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, + }; +} + +function oauthToken(accessToken: string, refreshToken: string) { + return { + ok: true, + access_token: accessToken, + refresh_token: refreshToken, + expires_in: 3600, + token_type: "Bearer", + }; +} + +function memberActor(allowedCompany: IdRef, isInstanceAdmin: boolean): Express.Request["actor"] { + return { + type: "board", + userId: "member-user", + userName: "Member User", + userEmail: null, + companyIds: [allowedCompany.id], + memberships: [ + { + companyId: allowedCompany.id, + membershipRole: "owner", + status: "active", + }, + ], + isInstanceAdmin: isInstanceAdmin, + source: "session", + }; +} + +function applicationRow(company: IdRef, name: string): typeof toolApplications.$inferInsert { + return { + companyId: company.id, + name: name, + type: "mcp_http", + status: "active", + }; +} + +function personalGrantRow(company: IdRef, connection: IdRef): typeof connectionGrants.$inferInsert { + return { + companyId: company.id, + connectionId: connection.id, + kind: "user", + subjectUserId: "alice", + status: "active", + isDefault: false, + }; +} + +function notionAuthorizationServer() { + return { + issuer: "https://mcp.notion.com", + authorization_endpoint: "https://mcp.notion.com/authorize", + token_endpoint: "https://mcp.notion.com/token", + registration_endpoint: "https://mcp.notion.com/register", + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }; +} + +function oauthCallbackInput(state: string): Parameters["completeOAuthCallback"]>[0] { + return { + state, + code: "oauth-code", + redirectUri: "http://paperclip.test/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "board" }, + }; +} + describeEmbeddedPostgres("tool access service", () => { let db!: ReturnType; let tempDb: Awaited< @@ -1438,14 +1522,7 @@ describeEmbeddedPostgres("tool access service", () => { const { connection } = await createBrokerConnection(db, company.id); const grant = await db .insert(connectionGrants) - .values({ - companyId: company.id, - connectionId: connection.id, - kind: "user", - subjectUserId: "alice", - status: "active", - isDefault: false, - }) + .values(personalGrantRow(company, connection)) .returning() .then((rows) => rows[0]!); const service = createTestToolAccessService(db); @@ -1518,14 +1595,7 @@ describeEmbeddedPostgres("tool access service", () => { const { connection } = await createBrokerConnection(db, company.id); const grant = await db .insert(connectionGrants) - .values({ - companyId: company.id, - connectionId: connection.id, - kind: "user", - subjectUserId: "alice", - status: "active", - isDefault: false, - }) + .values(personalGrantRow(company, connection)) .returning() .then((rows) => rows[0]!); @@ -1599,14 +1669,7 @@ describeEmbeddedPostgres("tool access service", () => { createdByUserId: "alice", }) .where(eq(toolConnections.id, connection.id)); - await db.insert(connectionGrants).values({ - companyId: company.id, - connectionId: connection.id, - kind: "user", - subjectUserId: "alice", - status: "active", - isDefault: false, - }); + await db.insert(connectionGrants).values(personalGrantRow(company, connection)); const response = await request( createRouteApp(db, boardSessionActor(company.id, "member", "mallory")), @@ -1623,14 +1686,7 @@ describeEmbeddedPostgres("tool access service", () => { const { connection } = await createBrokerConnection(db, company.id); const grant = await db .insert(connectionGrants) - .values({ - companyId: company.id, - connectionId: connection.id, - kind: "user", - subjectUserId: "alice", - status: "active", - isDefault: false, - }) + .values(personalGrantRow(company, connection)) .returning() .then((rows) => rows[0]!); const serviceDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); @@ -2390,11 +2446,7 @@ describeEmbeddedPostgres("tool access service", () => { }) .where(eq(toolCatalogEntries.toolName, "send_email")); fetchMock.mockResolvedValueOnce( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "send_email", description: "Send an email with attachments.", @@ -2407,9 +2459,7 @@ describeEmbeddedPostgres("tool access service", () => { }, annotations: { readOnlyHint: false }, }, - ], - }, - }), + ])), ); const secondRefresh = await service.refreshCatalog(connection.id); @@ -2539,12 +2589,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "list_labels", - description: "Lists Gmail labels.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("list_labels", "Lists Gmail labels."), { name: "create_label", description: "Creates a Gmail label.", @@ -2652,19 +2697,13 @@ describeEmbeddedPostgres("tool access service", () => { }), } as unknown as Response; } - return mcpSseResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpSseResponse(catalogResponse([ { name: "kv_get", description: "Read a value.", annotations: { readOnlyHint: true }, }, - ], - }, - }); + ])); }); const connection = await service.createConnection(company.id, { @@ -4689,22 +4728,7 @@ describeEmbeddedPostgres("tool access service", () => { effect: "include", toolName: "read_notes", }); - const app = createRouteApp(db, { - type: "board", - userId: "member-user", - userName: "Member User", - userEmail: null, - companyIds: [allowedCompany.id], - memberships: [ - { - companyId: allowedCompany.id, - membershipRole: "owner", - status: "active", - }, - ], - isInstanceAdmin: false, - source: "session", - }); + const app = createRouteApp(db, memberActor(allowedCompany, false)); const crossTenantResponses = [ await request(app).get(`/api/tool-profiles/${profile.id}/new-tools`), @@ -4768,22 +4792,7 @@ describeEmbeddedPostgres("tool access service", () => { const toolGateway = {} as ToolGatewayService; const app = createRouteApp( db, - { - type: "board", - userId: "member-user", - userName: "Member User", - userEmail: null, - companyIds: [allowedCompany.id], - memberships: [ - { - companyId: allowedCompany.id, - membershipRole: "owner", - status: "active", - }, - ], - isInstanceAdmin: true, - source: "session", - }, + memberActor(allowedCompany, true), toolGateway, ); const foreignOAuthRes = await request(app) @@ -5246,16 +5255,10 @@ describeEmbeddedPostgres("tool access service", () => { access: "all_agents", }); fetchMock.mockResolvedValueOnce( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "get_organization_details", annotations: { readOnlyHint: true } }, { name: "new_enterpret_tool", annotations: { readOnlyHint: true } }, - ], - }, - }), + ])), ); const refreshed = await service.refreshCatalog(result.connectionId, { actorType: "user", @@ -5305,17 +5308,11 @@ describeEmbeddedPostgres("tool access service", () => { }); reconnectFetchMock.mockResolvedValueOnce( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "get_organization_details", annotations: { readOnlyHint: true } }, { name: "new_enterpret_tool", annotations: { readOnlyHint: true } }, { name: "another_enterpret_tool", annotations: { readOnlyHint: true } }, - ], - }, - }), + ])), ); const afterReplacement = await service.refreshCatalog(result.connectionId, { actorType: "user", @@ -5628,11 +5625,7 @@ describeEmbeddedPostgres("tool access service", () => { .mockImplementation(async (_url, init) => { const headers = init?.headers as Record; expect(headers.Authorization).toBe("Bearer imported-token"); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "kv_get", description: "Read a value.", @@ -5642,9 +5635,7 @@ describeEmbeddedPostgres("tool access service", () => { }, annotations: { readOnlyHint: true }, }, - ], - }, - }); + ])); }); const result = await service.connectGalleryApp( @@ -5777,11 +5768,7 @@ describeEmbeddedPostgres("tool access service", () => { const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async () => - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools }, - }), + mcpHttpResponse(catalogResponse(tools)), ); const service = createTestToolAccessService(db, { now: () => currentTime, @@ -5960,15 +5947,9 @@ describeEmbeddedPostgres("tool access service", () => { observedAuthorization.push( new Headers(init.headers).get("authorization") ?? "", ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "query_insight", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); }, }); @@ -6145,15 +6126,9 @@ describeEmbeddedPostgres("tool access service", () => { evict: vi.fn(), }, remoteHttpRequest: async () => - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "search_pages", annotations: { readOnlyHint: true } }, - ], - }, - }), + ])), }); const connected = await service.connectGalleryApp( company.id, @@ -7114,12 +7089,7 @@ describeEmbeddedPostgres("tool access service", () => { const app = createRouteApp(db); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Google Sheets", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Google Sheets")) .returning(); const [connection] = await db .insert(toolConnections) @@ -7527,12 +7497,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "GitHub authorization fixture", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "GitHub authorization fixture")) .returning(); const [connection] = await db .insert(toolConnections) @@ -7637,18 +7602,12 @@ describeEmbeddedPostgres("tool access service", () => { }); } if (href === GITHUB_CONNECTOR_PROFILES["github.code"].serverUrl) { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "get_pull_request", annotations: { readOnlyHint: true }, }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -7898,15 +7857,9 @@ describeEmbeddedPostgres("tool access service", () => { }); } if (href === GITHUB_CONNECTOR_PROFILES["github.code"].serverUrl) { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "get_pull_request", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -9432,11 +9385,7 @@ describeEmbeddedPostgres("tool access service", () => { } as Response; } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); @@ -9467,6 +9416,8 @@ describeEmbeddedPostgres("tool access service", () => { await connectSlackWithTokenScope(null, ["channels:read"]); expect(authorizationScope).toBe("channels:read"); + // No provider scope assertion means extra scopes are unknown, not absent. + // An empty comparison cannot establish the token's real capabilities. expect(recordedOAuth).toMatchObject({ scope: null, scopeSource: "requested_fallback", @@ -9499,27 +9450,7 @@ describeEmbeddedPostgres("tool access service", () => { }); }); - // The limit of this fix, asserted so nobody reads `unrequestedScopes: []` as "no extra - // scopes were granted". This is exactly the Enterpret shape from PAP-18538: the token - // response omitted `scope` while the token really carried `mcp:write`, and only RFC 7662 - // introspection revealed it. Paperclip does not introspect, so the extra scope is - // *unknown*, not absent. `requested_fallback` is the marker that says so; an operator who - // needs certainty has to check at the provider. - it("cannot see an over-grant that the provider hides by omitting scope", async () => { - const { recordedOAuth } = await connectSlackWithTokenScope(null, [ - "channels:read", - ]); - expect(recordedOAuth).toMatchObject({ - scope: null, - // Not "provider": the scope list below is Paperclip's own request, so it carries no - // assurance about what the token can actually do. - scopeSource: "requested_fallback", - // Empty because nothing was asserted to compare against — NOT because the provider - // is known to have granted only what was asked. - unrequestedScopes: [], - }); - }); }); it("creates and resolves an agent-initiated user authorization grant card", async () => { @@ -9572,11 +9503,7 @@ describeEmbeddedPostgres("tool access service", () => { } as Response; } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); @@ -9886,17 +9813,11 @@ describeEmbeddedPostgres("tool access service", () => { const code = (init?.body as URLSearchParams).get("code"); expect(["personal-code", "personal-reconnect-code"]).toContain(code); const reconnecting = code === "personal-reconnect-code"; - return mcpHttpResponse({ - ok: true, - access_token: reconnecting + return mcpHttpResponse(oauthToken(reconnecting ? "personal-access-token-2" - : "personal-access-token", - refresh_token: reconnecting + : "personal-access-token", reconnecting ? "personal-refresh-token-2" - : "personal-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + : "personal-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { expect(init?.headers).toEqual( @@ -9906,19 +9827,13 @@ describeEmbeddedPostgres("tool access service", () => { ), }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true }, }, { name: "send_message", annotations: { readOnlyHint: false } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -10237,24 +10152,12 @@ describeEmbeddedPostgres("tool access service", () => { vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { - return mcpHttpResponse({ - ok: true, - access_token: "share-access-token", - refresh_token: "share-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("share-access-token", "share-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -10391,35 +10294,17 @@ describeEmbeddedPostgres("tool access service", () => { const body = init?.body as URLSearchParams; if (body.get("grant_type") === "refresh_token") { expect(body.get("refresh_token")).toBe("personal-refresh-token"); - return mcpHttpResponse({ - ok: true, - access_token: "refreshed-personal-access-token", - refresh_token: "rotated-personal-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("refreshed-personal-access-token", "rotated-personal-refresh-token")); } - return mcpHttpResponse({ - ok: true, - access_token: "personal-access-token", - refresh_token: "personal-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("personal-access-token", "personal-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true }, }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -10539,20 +10424,10 @@ describeEmbeddedPostgres("tool access service", () => { scope: "channels:read chat:write", }); } - return mcpHttpResponse({ - ok: true, - access_token: "personal-access-token", - refresh_token: "personal-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("personal-access-token", "personal-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); @@ -10642,11 +10517,7 @@ describeEmbeddedPostgres("tool access service", () => { json: async () => ({}), } as Response; } - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } if ( href === @@ -10770,11 +10641,7 @@ describeEmbeddedPostgres("tool access service", () => { }); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); @@ -10905,11 +10772,7 @@ describeEmbeddedPostgres("tool access service", () => { : slackToken(authorizationScope); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); @@ -11354,14 +11217,7 @@ describeEmbeddedPostgres("tool access service", () => { if ( href === "https://mcp.notion.com/.well-known/oauth-authorization-server" ) { - return mcpHttpResponse({ - issuer: "https://mcp.notion.com", - authorization_endpoint: "https://mcp.notion.com/authorize", - token_endpoint: "https://mcp.notion.com/token", - registration_endpoint: "https://mcp.notion.com/register", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - }); + return mcpHttpResponse(notionAuthorizationServer()); } if (href === "https://mcp.notion.com/register") { return mcpHttpResponse({ @@ -11390,15 +11246,9 @@ describeEmbeddedPostgres("tool access service", () => { Authorization: "Bearer notion-choice-access", }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "notion-search", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -11487,14 +11337,7 @@ describeEmbeddedPostgres("tool access service", () => { if ( href === "https://mcp.notion.com/.well-known/oauth-authorization-server" ) { - return mcpHttpResponse({ - issuer: "https://mcp.notion.com", - authorization_endpoint: "https://mcp.notion.com/authorize", - token_endpoint: "https://mcp.notion.com/token", - registration_endpoint: "https://mcp.notion.com/register", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - }); + return mcpHttpResponse(notionAuthorizationServer()); } if (href === "https://mcp.notion.com/register") { return mcpHttpResponse({ @@ -11632,11 +11475,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(init?.headers).toEqual( expect.objectContaining({ Authorization: "Bearer access-token" }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", description: "Search messages.", @@ -11647,9 +11486,7 @@ describeEmbeddedPostgres("tool access service", () => { description: "Send a message.", annotations: { readOnlyHint: false }, }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -12120,13 +11957,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(body.get("code")).toBe("oauth-code"); return { ok: true, - json: async () => ({ - ok: true, - access_token: "bound-access-token", - refresh_token: "bound-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }), + json: async () => (oauthToken("bound-access-token", "bound-refresh-token")), } as Response; } if (href === "https://mcp.slack.com/mcp") { @@ -12135,15 +11966,9 @@ describeEmbeddedPostgres("tool access service", () => { Authorization: "Bearer bound-access-token", }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -12186,14 +12011,7 @@ describeEmbeddedPostgres("tool access service", () => { href === "https://mcp.notion.com/.well-known/oauth-authorization-server" ) { - return mcpHttpResponse({ - issuer: "https://mcp.notion.com", - authorization_endpoint: "https://mcp.notion.com/authorize", - token_endpoint: "https://mcp.notion.com/token", - registration_endpoint: "https://mcp.notion.com/register", - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - }); + return mcpHttpResponse(notionAuthorizationServer()); } if (href === "https://mcp.notion.com/register") { expect(init?.method).toBe("POST"); @@ -12427,16 +12245,10 @@ describeEmbeddedPostgres("tool access service", () => { Authorization: "Bearer supabase-access-token", }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "list_tables", annotations: { readOnlyHint: true } }, { name: "execute_sql", annotations: { readOnlyHint: false } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -12583,13 +12395,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(new Headers(init?.headers).get("authorization")).toBe( "Bearer miro-access-token", ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [{ name: "whoami", annotations: { readOnlyHint: true } }], - }, - }); + return mcpHttpResponse(catalogResponse([{ name: "whoami", annotations: { readOnlyHint: true } }])); } throw new Error(`unexpected fetch ${href}`); }); @@ -13558,13 +13364,7 @@ describeEmbeddedPostgres("tool access service", () => { if (body.get("grant_type") === "authorization_code") { return { ok: true, - json: async () => ({ - ok: true, - access_token: "old-access-token", - refresh_token: "refresh-token", - expires_in: 3600, - token_type: "Bearer", - }), + json: async () => (oauthToken("old-access-token", "refresh-token")), } as Response; } expect(body.get("grant_type")).toBe("refresh_token"); @@ -13573,35 +13373,18 @@ describeEmbeddedPostgres("tool access service", () => { await new Promise((resolve) => setTimeout(resolve, 10)); return { ok: true, - json: async () => ({ - ok: true, - access_token: "new-access-token", - refresh_token: "new-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }), + json: async () => (oauthToken("new-access-token", "new-refresh-token")), } as Response; } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); - await service.completeOAuthCallback({ - state, - code: "oauth-code", - redirectUri: "http://paperclip.test/api/tools/oauth/callback", - actor: { actorType: "user", actorId: "board" }, - }); + await service.completeOAuthCallback(oauthCallbackInput(state)); const [connected] = await db .select() .from(toolConnections) @@ -13701,29 +13484,14 @@ describeEmbeddedPostgres("tool access service", () => { .mockImplementation(async (url) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { - return mcpHttpResponse({ - ok: true, - access_token: "expired-access-token", - refresh_token: "single-use-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("expired-access-token", "single-use-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); - await service.completeOAuthCallback({ - state, - code: "oauth-code", - redirectUri: "http://paperclip.test/api/tools/oauth/callback", - actor: { actorType: "user", actorId: "board" }, - }); + await service.completeOAuthCallback(oauthCallbackInput(state)); const [connected] = await db .select() .from(toolConnections) @@ -13824,29 +13592,14 @@ describeEmbeddedPostgres("tool access service", () => { .mockImplementation(async (url) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { - return mcpHttpResponse({ - ok: true, - access_token: "expired-access-token", - refresh_token: "submitted-refresh-token", - expires_in: 3600, - token_type: "Bearer", - }); + return mcpHttpResponse(oauthToken("expired-access-token", "submitted-refresh-token")); } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: [] }, - }); + return mcpHttpResponse(catalogResponse([])); } throw new Error(`unexpected fetch ${href}`); }); - await service.completeOAuthCallback({ - state, - code: "oauth-code", - redirectUri: "http://paperclip.test/api/tools/oauth/callback", - actor: { actorType: "user", actorId: "board" }, - }); + await service.completeOAuthCallback(oauthCallbackInput(state)); const [connected] = await db .select() .from(toolConnections) @@ -14010,15 +13763,9 @@ describeEmbeddedPostgres("tool access service", () => { Authorization: "Bearer m2m-access-token", }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "machine_read", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -14083,28 +13830,17 @@ describeEmbeddedPostgres("tool access service", () => { } as Response; } if (href === "https://mcp.slack.com/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "search_messages", annotations: { readOnlyHint: true }, }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); - await service.completeOAuthCallback({ - state, - code: "oauth-code", - redirectUri: "http://paperclip.test/api/tools/oauth/callback", - actor: { actorType: "user", actorId: "board" }, - }); + await service.completeOAuthCallback(oauthCallbackInput(state)); const [connected] = await db .select() .from(toolConnections) @@ -14687,12 +14423,7 @@ describeEmbeddedPostgres("tool access service", () => { .returning(); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Auto app", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Auto app")) .returning(); const [connection] = await db .insert(toolConnections) @@ -14767,12 +14498,7 @@ describeEmbeddedPostgres("tool access service", () => { const app = createRouteApp(db); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Attention review app", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Attention review app")) .returning(); const [connection] = await db .insert(toolConnections) @@ -14945,12 +14671,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "read_items", - description: "Read items.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("read_items", "Read items."), ]); const first = await service.connectGalleryApp( @@ -15362,12 +15083,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "read_items", - description: "Read items.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("read_items", "Read items."), ]); const first = await service.connectGalleryApp( @@ -15406,12 +15122,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "read_items", - description: "Read items.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("read_items", "Read items."), ]); const results = await Promise.all([ @@ -15450,12 +15161,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "read_items", - description: "Read items.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("read_items", "Read items."), ]); const first = await service.connectGalleryApp( company.id, @@ -15503,12 +15209,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ - { - name: "read_items", - description: "Read items.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("read_items", "Read items."), ]); const connect = await service.connectGalleryApp( @@ -16056,15 +15757,9 @@ describeEmbeddedPostgres("tool access service", () => { Authorization: "Bearer generic-access-token", }), ); - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "read_generic", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${href}`); }); @@ -16275,15 +15970,9 @@ describeEmbeddedPostgres("tool access service", () => { } as Response; } if (String(url) === "http://smoke-fixture.test/mcp") { - return mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + return mcpHttpResponse(catalogResponse([ { name: "todo.list", annotations: { readOnlyHint: true } }, - ], - }, - }); + ])); } throw new Error(`unexpected fetch ${String(url)}`); }); @@ -16351,12 +16040,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ - { - name: "list_zaps", - description: "List Zapier actions.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("list_zaps", "List Zapier actions."), { name: "update_zap", description: "Update a Zapier action.", @@ -16553,11 +16237,7 @@ describeEmbeddedPostgres("tool access service", () => { .where(eq(toolConnections.id, connect.connectionId)); fetchMock.mockResolvedValueOnce( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "list_zaps", description: "List Zapier actions.", @@ -16585,9 +16265,7 @@ describeEmbeddedPostgres("tool access service", () => { }, annotations: { readOnlyHint: false }, }, - ], - }, - }), + ])), ); const rereview = await service.refreshCatalog(connect.connectionId, { actorType: "user", @@ -16758,17 +16436,11 @@ describeEmbeddedPostgres("tool access service", () => { .where(eq(toolProfiles.profileKey, `app:${connect.connectionId}`)); expect(defaultProfile).toBeTruthy(); fetchMock.mockResolvedValueOnce( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "list_zaps", annotations: { readOnlyHint: true } }, { name: "update_zap", annotations: { readOnlyHint: false } }, { name: "create_zap", annotations: { readOnlyHint: false } }, - ], - }, - }), + ])), ); const refresh = await service.refreshCatalog(connect.connectionId, { @@ -17104,12 +16776,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ - { - name: "list_zaps", - description: "List Zapier actions.", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("list_zaps", "List Zapier actions."), { name: "update_zap", description: "Update a Zapier action.", @@ -17233,12 +16900,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ - { - name: "list_zaps", - description: "List", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("list_zaps", "List"), { name: "update_zap", description: "Update", @@ -17310,11 +16972,7 @@ describeEmbeddedPostgres("tool access service", () => { }) .where(eq(toolConnections.id, connect.connectionId)); fetchMock.mockResolvedValue( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { - tools: [ + mcpHttpResponse(catalogResponse([ { name: "list_zaps", description: "List", @@ -17333,9 +16991,7 @@ describeEmbeddedPostgres("tool access service", () => { inputSchema: { type: "object", properties: {} }, annotations: { destructiveHint: true }, }, - ], - }, - }), + ])), ); await expect( @@ -17442,12 +17098,7 @@ describeEmbeddedPostgres("tool access service", () => { const service = createTestToolAccessService(db); const userId = `personal-key-${randomUUID()}`; mockToolsList([ - { - name: "list_zaps", - description: "List", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }, + readTool("list_zaps", "List"), ]); const connected = await withGalleryServerUrl( @@ -17840,22 +17491,7 @@ describeEmbeddedPostgres("tool access service", () => { type: "mcp_http", }, ); - const app = createRouteApp(db, { - type: "board", - userId: "member-user", - userName: "Member User", - userEmail: null, - companyIds: [allowedCompany.id], - memberships: [ - { - companyId: allowedCompany.id, - membershipRole: "owner", - status: "active", - }, - ], - isInstanceAdmin: false, - source: "session", - }); + const app = createRouteApp(db, memberActor(allowedCompany, false)); const forbiddenRes = await request(app) .patch(`/api/tool-applications/${application.id}`) @@ -18384,22 +18020,7 @@ describeEmbeddedPostgres("tool access service", () => { type: "mcp_http", }, ); - const app = createRouteApp(db, { - type: "board", - userId: "member-user", - userName: "Member User", - userEmail: null, - companyIds: [allowedCompany.id], - memberships: [ - { - companyId: allowedCompany.id, - membershipRole: "owner", - status: "active", - }, - ], - isInstanceAdmin: false, - source: "session", - }); + const app = createRouteApp(db, memberActor(allowedCompany, false)); const forbiddenRes = await request(app).delete( `/api/tool-applications/${application.id}`, @@ -18449,12 +18070,7 @@ describeEmbeddedPostgres("tool access service", () => { .returning(); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Governed tools", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Governed tools")) .returning(); const [connection] = await db .insert(toolConnections) @@ -18628,12 +18244,7 @@ describeEmbeddedPostgres("tool access service", () => { .returning(); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "GitHub", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "GitHub")) .returning(); const [connection] = await db .insert(toolConnections) @@ -18805,12 +18416,7 @@ describeEmbeddedPostgres("tool access service", () => { .returning(); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Google Sheets", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Google Sheets")) .returning(); const [connection] = await db .insert(toolConnections) @@ -18948,12 +18554,7 @@ describeEmbeddedPostgres("tool access service", () => { const service = createTestToolAccessService(db); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Remote app", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Remote app")) .returning(); const [connection] = await db .insert(toolConnections) @@ -19158,12 +18759,7 @@ describeEmbeddedPostgres("tool access service", () => { }); const [application] = await db .insert(toolApplications) - .values({ - companyId: company.id, - name: "Setup apps", - type: "mcp_http", - status: "active", - }) + .values(applicationRow(company, "Setup apps")) .returning(); await db.insert(toolConnections).values([ { diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 0db14e6f47f..34d96ea95e6 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -169,6 +169,34 @@ function fakePluginDispatcher(): PluginToolDispatcher { }; } +type IdRef = { id: string }; + +function reviewNotePolicyRow(company: IdRef): typeof toolPolicies.$inferInsert { + return { + companyId: company.id, + name: "Review note writes", + policyType: "require_approval", + selectors: { toolName: "mcp-remote-fixture:update_note" }, + }; +} + +function readPolicyRow(company: IdRef, name: string): typeof toolPolicies.$inferInsert { + return { + companyId: company.id, + name: name, + policyType: "allow", + selectors: { riskLevel: "read" }, + }; +} + +function runSessionInput(company: IdRef, agent: IdRef, run: IdRef): Parameters["createSession"]>[0] { + return { + companyId: company.id, + agentId: agent.id, + runId: run.id, + }; +} + describeEmbeddedPostgres("tool gateway service", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; @@ -208,18 +236,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("gates write tools with an action request and executes only stored reviewed arguments once", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const notified = vi.fn(); const unsubscribe = subscribeDeliveryWork(db, DELIVERY_QUEUES.toolAction, notified); @@ -313,18 +332,9 @@ describeEmbeddedPostgres("tool gateway service", () => { companyId: company.id, runId: run.id, issueId: issue.id, responsibleUserId: null, cause: "company_default", }); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, @@ -379,7 +389,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }); const deliveries = toolActionDeliveryService(db, { wakeup }); const gateway = createTestToolGatewayService(db, { onToolActionSettled: deliveries.deliver }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "n1", body: "receipt" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const [request] = await db.select().from(toolActionRequests); await gateway.approveActionRequest({ companyId: company.id, actionRequestId: request.id, actor: { userId: "reviewer" } }); @@ -419,7 +429,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const wakeup = vi.fn(async (agentId: string, input: any) => (await db.insert(agentWakeupRequests).values({ companyId: company.id, agentId, source: input.source, idempotencyKey: input.idempotencyKey, payload: input.payload }).returning())[0] as any); const delivery = toolActionDeliveryService(db, { wakeup }); const gateway = createTestToolGatewayService(db, { onToolActionSettled: delivery.deliver }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); for (const noteId of ["one", "two"]) await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId, body: "reviewed body" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const requests = await db.select().from(toolActionRequests); expect(requests).toHaveLength(2); @@ -470,7 +480,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const { company, agent, run } = await createRunFixture(db); await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } }); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); for (let n = 0; n < 12; n++) { await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: `note-${n}`, body: "review me" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); } @@ -500,7 +510,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const { company, agent, issue, run } = await createRunFixture(db); await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } }); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "reassigned" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const [request] = await db.select().from(toolActionRequests); await gateway.declineActionRequest({ companyId: company.id, actionRequestId: request.id, actor: { userId: "reviewer" } }); @@ -518,7 +528,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const { company, agent, run } = await createRunFixture(db); await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } }); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "expire" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const [request] = await db.select().from(toolActionRequests); await db.update(toolActionRequests).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(toolActionRequests.id, request.id)); @@ -537,7 +547,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const { company, agent, issue, run } = await createRunFixture(db); await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } }); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "expire" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const [expiring] = await db.select().from(toolActionRequests); await db.update(toolActionRequests).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(toolActionRequests.id, expiring.id)); @@ -572,7 +582,7 @@ describeEmbeddedPostgres("tool gateway service", () => { if (body.method === "tools/call") calls++; return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: "Roadmap and meeting notes" }] } }), { headers: { "content-type": "application/json" } }); } }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === connection.id)!; const call = (limit: number) => gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { limit } }); await expect(call(10)).rejects.toMatchObject({ reasonCode: "approval_required" }); @@ -611,7 +621,7 @@ describeEmbeddedPostgres("tool gateway service", () => { const { company, agent, run } = await createRunFixture(db); await db.insert(toolPolicies).values({ companyId: company.id, name: "Ask first", policyType: "require_approval", selectors: { toolName: "mcp-remote-fixture:update_note" } }); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters: { noteId: "n1" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); const [request] = await db.select().from(toolActionRequests); // This internal fixture has no connection and cannot grant action-wide access. @@ -624,14 +634,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("refuses to approve an action request through a different interaction", async () => { const { company, agent, issue, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, @@ -654,14 +659,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("prevents another run from consuming an approved action request by id", async () => { const { company, agent, issue, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const originatingSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const originatingSession = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: originatingSession.token, @@ -702,14 +702,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("executes an approved identical-args race once and returns the winner result", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const parameters = { noteId: "n1", body: "race body" }; await expect(gateway.executeTool({ @@ -737,14 +732,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("refuses to execute an approved action after its issue closes", async () => { const { company, agent, issue, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const parameters = { noteId: "n1", body: "post-close body" }; await expect(gateway.executeTool({ @@ -774,14 +764,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("keeps pre-execute-on-approve approved requests inert", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const parameters = { noteId: "n1", body: "legacy" }; await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters })) .rejects.toMatchObject({ reasonCode: "approval_required" }); @@ -833,12 +818,7 @@ describeEmbeddedPostgres("tool gateway service", () => { it("does not let a stale legacy consumer overwrite the winning approved execution", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); let observeLegacyClaim!: () => void; const legacyClaimObserved = new Promise((resolve) => { @@ -854,11 +834,7 @@ describeEmbeddedPostgres("tool gateway service", () => { await legacyClaimBlocked; }, }); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const parameters = { noteId: "n1", body: "legacy race" }; await expect(gateway.executeTool({ @@ -941,18 +917,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("does not leave unsigned action requests pending when signing is unavailable", async () => { vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", ""); const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db, { toolActionSigningSecret: " " }); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, @@ -993,14 +960,9 @@ describeEmbeddedPostgres("tool gateway service", () => { status: "running", contextSnapshot: {}, }).returning().then((rows) => rows[0]!); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, @@ -1016,18 +978,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("cancels a stale pending action request when direct approval sees an invalid signature", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", @@ -1053,18 +1006,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("declines a pending action request and rejects the invocation (PAP-10859)", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); await expect(gateway.executeTool({ sessionToken: session.token, @@ -1107,14 +1051,9 @@ describeEmbeddedPostgres("tool gateway service", () => { it("expires a stale identical request and creates a fresh approval", async () => { const { company, agent, run } = await createRunFixture(db); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Review note writes", - policyType: "require_approval", - selectors: { toolName: "mcp-remote-fixture:update_note" }, - }); + await db.insert(toolPolicies).values(reviewNotePolicyRow(company)); const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const parameters = { noteId: "n1", body: "expires" }; await expect(gateway.executeTool({ sessionToken: session.token, tool: "mcp-remote-fixture:update_note", parameters })) .rejects.toMatchObject({ reasonCode: "approval_required" }); @@ -1138,11 +1077,7 @@ describeEmbeddedPostgres("tool gateway service", () => { selectors: { toolName: "fixture:delete_everything" }, }); const gateway = createTestToolGatewayService(db, { pluginToolDispatcher: fakePluginDispatcher() }); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); let approvalRequired: ToolGatewayHttpError | null = null; try { @@ -1204,12 +1139,7 @@ describeEmbeddedPostgres("tool gateway service", () => { it("maps remote MCP elicitation to a durable issue interaction", async () => { const { company, agent, run } = await createRunFixture(db); await createRemoteMcpToolFixture(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow read tools", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools")); const originalFetch = globalThis.fetch; globalThis.fetch = async (_url, init) => new Response(JSON.stringify({ jsonrpc: "2.0", @@ -1235,11 +1165,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); try { const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); expect(tool).toBeTruthy(); @@ -1296,12 +1222,7 @@ describeEmbeddedPostgres("tool gateway service", () => { ...(persistPreference ? { mcpSessionRequired: true } : {}), }, }).where(eq(toolConnections.id, connection.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow read tools", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools")); const requests: Array<{ method: string; sessionId: string | null; protocolVersion: string | null }> = []; const gateway = createTestToolGatewayService(db, { remoteHttpRequest: async (_url, init) => { @@ -1334,11 +1255,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); }, }); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1359,12 +1276,7 @@ describeEmbeddedPostgres("tool gateway service", () => { it("keeps direct tools/call for generic connections without the curated session preference", async () => { const { company, agent, run } = await createRunFixture(db); const { connection } = await createRemoteMcpToolFixture(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow read tools", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools")); const requests: string[] = []; const gateway = createTestToolGatewayService(db, { remoteHttpRequest: async (_url, init) => { @@ -1377,7 +1289,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }); }, }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1406,9 +1318,7 @@ describeEmbeddedPostgres("tool gateway service", () => { } }, } }, }).where(eq(toolCatalogEntries.id, catalogEntry.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, name: "Allow Chat reads", policyType: "allow", selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow Chat reads")); const provider = vi.fn(async (_url, init) => { const payload = JSON.parse(String(init.body)); if (payload.method === "notifications/initialized") return new Response(null, { status: 202 }); @@ -1417,7 +1327,7 @@ describeEmbeddedPostgres("tool gateway service", () => { : { content: [{ type: "text", text: "Found matching messages." }] } }); }); const gateway = createTestToolGatewayService(db, { remoteHttpRequest: provider }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.upstreamToolName === "search_messages")!; expect(tool).toBeTruthy(); @@ -1457,12 +1367,7 @@ describeEmbeddedPostgres("tool gateway service", () => { sourceTemplateKey: "google-drive", }, }).where(eq(toolConnections.id, connection.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow Drive reads", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow Drive reads")); const gateway = createTestToolGatewayService(db, { remoteHttpRequest: async (_url, init) => { const requestBody = JSON.parse(String(init.body)) as { id: string }; @@ -1476,11 +1381,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); }, }); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1517,12 +1418,7 @@ describeEmbeddedPostgres("tool gateway service", () => { externalCredential: { provider: "vercel_connect", subjectType: "app" }, credentialSecretRefs: [], }).where(eq(connectionGrants.connectionId, connection.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow Vercel-backed reads", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow Vercel-backed reads")); const getToken = vi.fn(async (_request, options) => ({ token: options?.forceRefresh ? "fresh-provider-bearer" : "stale-provider-bearer", @@ -1552,7 +1448,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); }, }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1596,8 +1492,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }))).returning(); await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id, responsibleUserId: "A", cause: "instruction" }); - await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads", - policyType: "allow", selectors: { riskLevel: "read" } }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow reads")); const resolvedGrants: string[] = []; const gateway = createTestToolGatewayService(db, { oauthGrantRefresher: async ({ grantId }) => { @@ -1608,7 +1503,7 @@ describeEmbeddedPostgres("tool gateway service", () => { id: JSON.parse(String(init.body)).id, result: { content: [{ type: "text", text: "ok" }] }, }), { status: 200, headers: { "content-type": "application/json" } }), }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)).find(t => t.providerType === "mcp_remote_http")!; for (const [index, user] of ["A", "B", "A"].entries()) { if (index > 0) { @@ -1655,8 +1550,7 @@ describeEmbeddedPostgres("tool gateway service", () => { } await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id, responsibleUserId: "A", cause: "instruction" }); - await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads", - policyType: "allow", selectors: { riskLevel: "read" } }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow reads")); const refreshed: string[] = []; const dispatched = vi.fn(async (_url: string, init: RequestInit) => { expect(new Headers(init.headers).get("authorization")).toBe(`Bearer token-${upstreamFailure ? 1 : 0}`); @@ -1674,7 +1568,7 @@ describeEmbeddedPostgres("tool gateway service", () => { return grants.find(grant => grant.id === grantId)!; }, remoteHttpRequest: dispatched, }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)).find(t => t.providerType === "mcp_remote_http")!; const execution = gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }); if (upstreamFailure) await expect(execution).rejects.toMatchObject({ status: 502 }); @@ -1732,12 +1626,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }, }, }).where(eq(connectionGrants.id, grant.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow customer OAuth reads", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow customer OAuth reads")); const oauthGrantRefresher: NonNullable = vi.fn(async (input) => { if (input.forceRefresh) { @@ -1784,7 +1673,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); }, }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1865,12 +1754,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }, }, }).where(eq(connectionGrants.id, grant.id)); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow managed OAuth reads", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow managed OAuth reads")); const oauthGrantRefresher: NonNullable = vi.fn(async (input) => { if (input.forceRefresh) { @@ -1887,7 +1771,7 @@ describeEmbeddedPostgres("tool gateway service", () => { return new Response(null, { status: 401 }); }, }); - const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); @@ -1926,12 +1810,7 @@ describeEmbeddedPostgres("tool gateway service", () => { contextSnapshot: {}, }).returning().then((rows) => rows[0]!); await createRemoteMcpToolFixture(db, company.id); - await db.insert(toolPolicies).values({ - companyId: company.id, - name: "Allow read tools", - policyType: "allow", - selectors: { riskLevel: "read" }, - }); + await db.insert(toolPolicies).values(readPolicyRow(company, "Allow read tools")); const originalFetch = globalThis.fetch; globalThis.fetch = async (_url, init) => new Response(JSON.stringify({ jsonrpc: "2.0", @@ -1940,11 +1819,7 @@ describeEmbeddedPostgres("tool gateway service", () => { }), { status: 200, headers: { "content-type": "application/json" } }); try { const gateway = createTestToolGatewayService(db); - const session = await gateway.createSession({ - companyId: company.id, - agentId: agent.id, - runId: run.id, - }); + const session = await gateway.createSession(runSessionInput(company, agent, run)); const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); expect(tool).toBeTruthy(); @@ -2073,7 +1948,7 @@ describeEmbeddedPostgres("tool gateway service", () => { actor: { type: "agent", companyId: company.id, agentId: agent.id, runId: run.id }, tool: "fixture:read_status", parameters: { query: "ok", apiKey: "sk-secret-value" }, - runContext: { companyId: company.id, agentId: agent.id, runId: run.id }, + runContext: runSessionInput(company, agent, run), }); expect(executedParameters).toEqual({ query: "ok", apiKey: "sk-secret-value" }); diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index 5c5731659d2..222f97d476c 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -93,7 +93,7 @@ export function isSecretSensitiveHttpRequest( // Avatar uploads and dedicated Dot MCP calls may contain private image bytes. if (url && /^(?:\/mcp\/runner(?:\/|$)|\/api\/companies\/[^/]+\/agents\/[^/]+\/avatar(?:\/|$))/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (!method || !url) return false; - if (/^\/api\/chat-slack\/oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; + if (/^\/api\/chat-slack\/(?:managed\/)?oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (/^\/api\/chat-endpoints\/[^/]+\/slack(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (!SECRET_SENSITIVE_HTTP_METHODS.has(method.toUpperCase())) return false; const pathname = normalizePath(url); diff --git a/server/src/middleware/redact-sensitive.ts b/server/src/middleware/redact-sensitive.ts index 0b4ba2d39d4..3d6b874f6d7 100644 --- a/server/src/middleware/redact-sensitive.ts +++ b/server/src/middleware/redact-sensitive.ts @@ -16,6 +16,13 @@ const SENSITIVE_KEYS = new Set([ // material under `credentials`. Redact the whole subtree instead of trying // to keep an ever-changing allowlist of provider-specific field names in // sync with every connector. + "sealed", + "handoff", + "claim", + "claimid", + "claim_id", + "managertoken", + "manager_token", "configurationtoken", "configuration_token", "credential", diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 6395e5ecd89..6dbde753c35 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -1,4 +1,4 @@ -import { slackRegistrationSchema, slackSetupActionSchema } from "@paperclipai/shared"; +import { slackTestMessageSchema, slackRegistrationSchema, slackSetupActionSchema, slackManagedProvisionSchema } from "@paperclipai/shared"; import { isCrossSiteOAuthCallbackNavigation, oauthCallbackInterstitialHtml } from "../lib/oauth-browser-return.js"; import { Router, @@ -164,6 +164,10 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { async (req, res) => { const companyId = req.params.companyId as string; await assertConnectionManager(req, companyId); + if (req.body.provider === "slack" && !req.body.slackSetupMethod) { + req.body.slackSetupMethod = service.slackRegistration.managerGrants.defaultMethod(); + } + if (req.body.slackSetupMethod === "managed" && service.slackRegistration.managerGrants.defaultMethod() !== "managed") throw forbidden("Managed Slack setup is unavailable"); res .status(201) .json(await service.create(companyId, req.body, actorUserId(req))); @@ -182,6 +186,50 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { return { userId: actor.actorId, sessionId: actor.sessionId, bypassPermissionCheck: req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true }; }; + router.get("/companies/:companyId/chat-slack/setup-options", async (req, res) => { + const companyId = req.params.companyId as string; + await assertConnectionManager(req, companyId); + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managerGrants.choices(companyId, slackActor(req))); + }); + router.post("/chat-endpoints/:endpointId/slack/managed/authorize", validate(slackSetupActionSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managerGrants.authorize(endpointId(req), slackActor(req))); + }); + router.post("/chat-endpoints/:endpointId/slack/managed/provision", validate(slackManagedProvisionSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managed.provision(endpointId(req), slackActor(req), req.body)); + }); + router.delete("/companies/:companyId/chat-slack/manager-grants/:grantId", async (req, res) => { + const companyId = req.params.companyId as string; + await assertConnectionManager(req, companyId); + await service.slackRegistration.managerGrants.revoke(req.params.grantId as string, companyId, slackActor(req)); + res.sendStatus(204); + }); + router.get("/chat-slack/managed/oauth/callback", async (req, res) => { + res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); + const actor = slackActor(req); + const state = typeof req.query.state === "string" ? req.query.state : ""; + const expiredId = await service.slackRegistration.managerGrants.expiredReturn(state, actor); + if (expiredId) { res.redirect(303, await service.slackRegistration.returnPath(expiredId)); return; } + const pending = await service.slackRegistration.managerGrants.pending(state, actor); + await assertConnectionManager(req, pending.current.endpoint.companyId); + if (req.get("accept")?.includes("text/html") && isCrossSiteOAuthCallbackNavigation(req)) { + res.type("html").send(oauthCallbackInterstitialHtml()); return; + } + const id = await service.slackRegistration.managerGrants.complete(state, + typeof req.query.claim_id === "string" ? req.query.claim_id : null, + typeof req.query.error === "string" ? req.query.error : null, actor, + typeof req.query.code === "string" ? req.query.code : null); + res.redirect(303, await service.slackRegistration.returnPath(id)); + }); + router.post("/chat-endpoints/:endpointId/slack/test-message", validate(slackTestMessageSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.sendSlackTestMessage(endpointId(req), slackActor(req), req.body)); + }); router.post("/chat-endpoints/:endpointId/slack/registration", validate(slackRegistrationSchema), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; res.set("Cache-Control", "no-store"); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index f39cd53431a..9b113c62805 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1,5 +1,5 @@ import { updateFastResponseSchema } from "@paperclipai/shared"; -import { slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared"; +import { slackTestMessageSchema, slackTestMessageResultSchema, slackManagerAuthorizationSchema, slackSetupOptionsSchema, slackManagedProvisionSchema, slackManagedResultSchema, slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared"; import { experimentalApiMetadata } from "./experimental-api-metadata.js"; import { experimentalApiPaths, @@ -803,8 +803,9 @@ const chatAdapterCapabilitiesResponseSchema = z const chatEndpointSetupResponseSchema = z .object({ step: z.enum(["choose_agent", "provider_setup", "test", "complete"]), - slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), slackRegistration: slackRegistrationStateSchema.optional(), + slackManagerError: z.string().optional(), slackAvatar: slackAvatarStateSchema.optional(), slackAccount: slackAccountStateSchema.optional(), slackOAuthCallbackUri: z.string().nullable().optional(), @@ -2563,6 +2564,41 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", path: "/api/companies/{companyId}/chat-slack/setup-options", tags: ["chat-channels"], + summary: "Discover Slack setup methods and the current user's authorized workspaces", + description: "Managed setup is advertised only for approved Cloud deployments with the broker profile enabled. Self-hosted discovery makes no hosted-service requests. Grants are personal and company scoped.", + request: { params: z.object({ companyId: z.string().uuid() }) }, responses: { 200: r.ok(slackSetupOptionsSchema), 401: r.unauthorized, 403: r.forbidden }, +}); +for (const action of ["authorize", "provision"] as const) registry.registerPath({ + method: "post", path: `/api/chat-endpoints/{endpointId}/slack/managed/${action}`, tags: ["chat-channels"], + summary: action === "authorize" ? "Authorize a personal Slack manager workspace grant" : "Provision the saved managed Slack app", + description: "Requires the selected managed method, company membership, connection-management permission and Cloud availability. Authorization state is actor/session bound and single use. Provisioning accepts a personal grant ID and idempotent request ID; no provider credentials or arbitrary manifests are accepted. Explicit reconnect uses reconnect=true with a fresh request ID to reinstall the saved app. Repeating that reconnect request returns its saved result without reinstalling a healthy connection.", + request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(action === "authorize" ? slackSetupActionSchema : slackManagedProvisionSchema) }, + responses: { 200: r.ok(action === "authorize" ? slackManagerAuthorizationSchema : slackManagedResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable }, +}); +registry.registerPath({ + method: "delete", path: "/api/companies/{companyId}/chat-slack/manager-grants/{grantId}", tags: ["chat-channels"], + summary: "Revoke the current user's workspace grant for future managed provisioning", + description: "Removes local manager credentials and invalidates pending manager authorization. Installed bots remain connected; this does not uninstall Slack apps.", + request: { params: z.object({ companyId: z.string().uuid(), grantId: z.string().uuid() }) }, responses: { 204: { description: "Personal manager grant revoked" }, 401: r.unauthorized, 403: r.forbidden }, +}); +registry.registerPath({ + method: "get", path: "/api/chat-slack/managed/oauth/callback", tags: ["chat-channels"], + summary: "Redeem a sealed manager authorization and return to Slack setup", + description: "Authenticated, same-origin continuation with company, actor, session, endpoint revision and configured-origin checks. The broker handoff is single use; credentials are never returned to the browser. Explicit local development qualification accepts a Slack code instead of a broker claim; production ignores code.", + request: { query: z.object({ state: z.string(), claim_id: z.string().optional(), code: z.string().optional(), error: z.string().optional() }) }, + responses: { 200: { description: "Same-origin continuation page" }, 303: { description: "Return to saved setup" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict }, +}); + +registry.registerPath({ + method: "post", path: "/api/chat-endpoints/{endpointId}/slack/test-message", tags: ["chat-channels"], + summary: "Connect the current user's Slack identity and send a test DM", + description: "Requires a board connection manager and active company membership. Reuses only the actor's verified identity in the same workspace; otherwise returns paginated account choices and sends a private connection challenge. The account holder must confirm it while signed in before linking or test delivery. Revoked and conflicting links cannot be overwritten. The request ID prevents duplicate or ambiguous sends from being replayed.", + request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(slackTestMessageSchema) }, + responses: { 200: r.ok(slackTestMessageResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable, 429: { description: "Wait before sending another test message" } }, +}); + for (const action of ["registration", "install", "resume"] as const) { registry.registerPath({ method: "post", path: `/api/chat-endpoints/{endpointId}/slack/${action}`, tags: ["chat-channels"], diff --git a/server/src/services/__tests__/run-failure-report.test.ts b/server/src/services/__tests__/run-failure-report.test.ts index 0c1d83de7b7..111be4faf60 100644 --- a/server/src/services/__tests__/run-failure-report.test.ts +++ b/server/src/services/__tests__/run-failure-report.test.ts @@ -301,6 +301,39 @@ describeEmbeddedPostgres("reportRunFailure", () => { expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ agentAdapter: "unknown" })); }); + function providerQuotaRun(overrides: Partial = {}) { + return buildRun({ + error: "ACP agent reported a terminal limit failure.\nYou've hit your session limit · resets 2am (UTC)", + errorCode: "provider_quota", executionStage: "dispatching", exitCode: 1, signal: null, + resultJson: { errorFamily: "provider_quota", stopReason: "adapter_failed" }, ...overrides, + }); + } + + it.each(["claude_local", "codex_local"])("keeps a classified %s provider quota wait local", async (adapterType) => { + await seedCompanyAndAgent({ adapterType }); + const [run] = await db.insert(heartbeatRuns).values(providerQuotaRun()).returning(); + const before = structuredClone(run); + for (const phase of [undefined, "execute"] as const) await reportRunFailure(db, run!, { phase }); + expect(mockCaptureRunFailure).not.toHaveBeenCalled(); + expect(await db.select().from(heartbeatRuns)).toEqual([before]); + }); + + it.each([ + { status: "timed_out" }, { status: "cancelled", startedAt: new Date(0) }, { signal: "SIGTERM" }, + { errorCode: "adapter_failed" }, { errorCode: "claude_transient_upstream" }, + { resultJson: {} }, { resultJson: null }, { resultJson: { errorFamily: "transient_upstream" } }, + ])("reports a provider quota outcome without matching adapter evidence: %j", async (overrides) => { + await seedCompanyAndAgent({ adapterType: "claude_local" }); + await reportRunFailure(db, providerQuotaRun(overrides), { phase: "execute" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + + it("keeps a provider quota code reportable during setup", async () => { + await seedCompanyAndAgent({ adapterType: "claude_local" }); + await reportRunFailure(db, providerQuotaRun(), { phase: "setup" }); + expect(mockCaptureRunFailure).toHaveBeenCalledOnce(); + }); + function aiSelectionRun(overrides: Partial = {}) { return buildRun({ errorCode: "configuration_incomplete", diff --git a/server/src/services/adapter-execution-control.test.ts b/server/src/services/adapter-execution-control.test.ts index 7167e51f249..cc030434897 100644 --- a/server/src/services/adapter-execution-control.test.ts +++ b/server/src/services/adapter-execution-control.test.ts @@ -5,6 +5,7 @@ import { captureAdapterStopOwnership, createAdapterExecutionControl, registerAdapterExecutionControl, + reportAdapterStopIfUnsettled, waitForAdapterStop, } from "./adapter-execution-control.js"; @@ -204,3 +205,72 @@ it.each(["replaced", "wrong_run", "wrong_promise", "throwing_reader"])("omits pe other.finish(); adapterExecutionControls.delete(runId); }); + +it("reports a Stop once, with its pending phase, only when it stays unsettled", async () => { + vi.useFakeTimers(); + const runId = "11111111-1111-4111-8111-111111111111"; + const control = createAdapterExecutionControl(); + await registerAdapterExecutionControl(runId, control); + const report = vi.fn(); + try { + control.phases.enter("workspace_restore"); + reportAdapterStopIfUnsettled(control.settled, report, { runId, adapterType: "codex_local" }, control, { reportAfterMs: 5000 }); + // A repeated Stop for the same execution must not add another report. + reportAdapterStopIfUnsettled(control.settled, report, { runId }, control, { reportAfterMs: 5000 }); + await vi.advanceTimersByTimeAsync(4999); + expect(report).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(report).toHaveBeenCalledOnce(); + const error = report.mock.calls[0]![0] as AdapterStopTimeoutError; + expect(error).toBeInstanceOf(AdapterStopTimeoutError); + expect(error.diagnostics).toMatchObject({ + runId, adapterType: "codex_local", timeoutMs: 5000, phase: "workspace_restore", phaseElapsedMs: expect.any(Number), + }); + expect(vi.getTimerCount()).toBe(0); + expect(adapterExecutionControls.get(runId)).toBe(control); + } finally { + control.finish(); + adapterExecutionControls.delete(runId); + } +}); + +it("does not report a Stop that settles before the report bound", async () => { + vi.useFakeTimers(); + const control = createAdapterExecutionControl(); + const report = vi.fn(); + reportAdapterStopIfUnsettled(control.settled, report, undefined, control, { reportAfterMs: 5000 }); + await vi.advanceTimersByTimeAsync(1000); + control.finish(); + await control.settled; + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(10_000); + expect(report).not.toHaveBeenCalled(); +}); + +it("measures the report bound from the Stop request, including its response wait", async () => { + vi.useFakeTimers(); + const control = createAdapterExecutionControl(); + const report = vi.fn(); + const stopRequestedAt = Date.now(); + // The Stop request's own response wait elapses before the watch starts. + const responseWait = stopTimeout(waitForAdapterStop(control.settled, 2000)); + await vi.advanceTimersByTimeAsync(2000); + await responseWait; + reportAdapterStopIfUnsettled(control.settled, report, undefined, control, { stopRequestedAt, reportAfterMs: 5000 }); + await vi.advanceTimersByTimeAsync(2999); + expect(report).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(report).toHaveBeenCalledOnce(); + expect((report.mock.calls[0]![0] as AdapterStopTimeoutError).diagnostics).toMatchObject({ timeoutMs: 5000 }); + control.finish(); +}); + +it("keeps a failing reporter from escaping its timer", async () => { + vi.useFakeTimers(); + const control = createAdapterExecutionControl(); + const report = vi.fn(() => { throw new Error("reporter down"); }); + reportAdapterStopIfUnsettled(control.settled, report, undefined, undefined, { reportAfterMs: 1000 }); + await vi.advanceTimersByTimeAsync(1000); + expect(report).toHaveBeenCalledOnce(); + control.finish(); +}); diff --git a/server/src/services/adapter-execution-control.ts b/server/src/services/adapter-execution-control.ts index 06f60cab9fb..9b1b9f17d08 100644 --- a/server/src/services/adapter-execution-control.ts +++ b/server/src/services/adapter-execution-control.ts @@ -68,9 +68,9 @@ export async function registerAdapterExecutionControl( adapterExecutionControls.set(runId, control); } -export async function waitForAdapterStop( +function adapterStopTimeoutError( settled: Promise, - timeoutMs = 60_000, + timeoutMs: number, diagnostics?: AdapterStopContext, owner?: ReturnType, ) { @@ -83,21 +83,27 @@ export async function waitForAdapterStop( return null; } }; + return new AdapterStopTimeoutError(timeoutMs, { + ...diagnostics, + phase: undefined, + phaseElapsedMs: undefined, + ...currentPhase(), + }); +} + +export async function waitForAdapterStop( + settled: Promise, + timeoutMs = 60_000, + diagnostics?: AdapterStopContext, + owner?: ReturnType, +) { let timer: ReturnType | undefined; try { await Promise.race([ settled, new Promise((_, reject) => { timer = setTimeout( - () => - reject( - new AdapterStopTimeoutError(timeoutMs, { - ...diagnostics, - phase: undefined, - phaseElapsedMs: undefined, - ...currentPhase(), - }), - ), + () => reject(adapterStopTimeoutError(settled, timeoutMs, diagnostics, owner)), timeoutMs, ); }), @@ -106,3 +112,42 @@ export async function waitForAdapterStop( clearTimeout(timer); } } + +// A Stop request's wait is a response budget, not the adapter's settlement +// bound: workspace sync-back and cooperative provider cancellation routinely +// settle a few minutes after it. Only a Stop still unsettled this long after +// it was requested is an operational failure worth reporting. +export const UNSETTLED_ADAPTER_STOP_REPORT_MS = 10 * 60_000; +const watchedAdapterStops = new WeakSet>(); + +/** Reports at most once per execution, and only if it never settles in time. */ +export function reportAdapterStopIfUnsettled( + settled: Promise, + report: (error: AdapterStopTimeoutError) => void, + diagnostics?: AdapterStopContext, + owner?: ReturnType, + { + stopRequestedAt = Date.now(), + reportAfterMs = UNSETTLED_ADAPTER_STOP_REPORT_MS, + }: { stopRequestedAt?: number; reportAfterMs?: number } = {}, +) { + if (watchedAdapterStops.has(settled)) return; + watchedAdapterStops.add(settled); + let done = false; + // The bound runs from the Stop request, which already spent its response wait. + const remainingMs = Math.max(0, stopRequestedAt + reportAfterMs - Date.now()); + const timer = setTimeout(() => { + if (done) return; + try { + report(adapterStopTimeoutError(settled, reportAfterMs, diagnostics, owner)); + } catch { + // Reporting is diagnostic only. + } + }, remainingMs); + // Never hold process shutdown open for a diagnostic. + timer.unref?.(); + void settled.then(() => { + done = true; + clearTimeout(timer); + }); +} diff --git a/server/src/services/ai-connection-router.ts b/server/src/services/ai-connection-router.ts index 1e235ef391b..5bea58456d2 100644 --- a/server/src/services/ai-connection-router.ts +++ b/server/src/services/ai-connection-router.ts @@ -55,7 +55,6 @@ export function poolMemberRuntimeConfig(member: AiConnectionPoolMember, adapterT const efforts = provider === "openai" ? codexLocalReasoningEffortsForModel(model) : provider === "anthropic" ? claudeLocalReasoningEffortsForModel(model) : provider === "xai" ? grokLocalReasoningEffortsForModel(model) : []; - // Claude and Codex carry effort through native admission; other ACPX profiles keep their existing limit. const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" || (profile.provider === "acpx" && profile.acpxAgent === "claude") ? "modelReasoningEffort" : null : provider === "anthropic" ? "effort" : provider === "xai" ? "reasoningEffort" : provider === "openai" ? "modelReasoningEffort" : "variant"; const requested = overrides.modelReasoningEffort ?? overrides.reasoningEffort ?? overrides.effort ?? overrides.variant; diff --git a/server/src/services/anthropic-pricing.ts b/server/src/services/anthropic-pricing.ts index 63348e36cf5..1823a0adbc2 100644 --- a/server/src/services/anthropic-pricing.ts +++ b/server/src/services/anthropic-pricing.ts @@ -1,9 +1,6 @@ import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared"; import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils"; -// Direct Anthropic API list prices, verified per model below: -// https://platform.claude.com/docs/en/about-claude/pricing -// Keep this bounded to exact qualified models; aliases and other billers stay unknown. const RATE_CARDS: Record = { "claude-sonnet-5": { rates: ["2", "0.2", "4", "10"], version: "anthropic-standard-2026-10-07" }, "claude-opus-5-5": { rates: ["4", "0.2", "8", "20"], version: "anthropic-standard-2026-10-12" }, diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 31338f7acc1..6a302b30fdc 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,3 +1,22 @@ +import { slackTestMessageService } from "./connectors/slack/test-message.js"; +import type { SlackSetupOptions } from "./connectors/slack/setup/registration.js"; +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 { createTelegramCallbackNoticeAuthority } from "./chat-channels/telegram/callback-notices.js"; +import { createOutboundAttachments, isOutboundAttachmentHydrationError, isOutboundAttachmentValidationError } 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 { 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"; @@ -5,10 +24,10 @@ import { createSlackFileReceipts } from "./chat-channels/slack/file-receipts.js" import { createSlackSessionStatus } from "./chat-channels/slack/session-status.js"; import { createDiscordCommands } from "./chat-channels/discord/commands.js"; import { createSlackTaskStart, slackSlashTaskRecoveryPayload, slackTaskStarterThreadId } from "./chat-channels/slack/task-start.js"; -import { createGitHubWebhookIngress, GITHUB_RECOVERY_STATE_KEY, type GitHubIngressStageResult } from "./chat-channels/github/webhook-ingress.js"; +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 } from "./chat-channels/runtime-types.js"; +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext, SlackCallbackSurface, 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"; @@ -48,7 +67,7 @@ import { type GitHubLifecycleEvent, githubLifecycleEventFromRequest, } from "./chat-channels/github/messages.js"; -import { dueChatAction, nextChatActionAt, SLACK_COMMAND_POST_STALE_MS, SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS, PROVIDER_EFFECT_STALE_MS } from "./chat-action-work.js"; +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"; @@ -61,9 +80,7 @@ import type { ChatEndpointRuntime } from "./voice/voice-runtime.js"; import { parseMarkdown } from "chat"; import { githubPublicAttachmentsFromMessage } from "./chat-github-attachments.js"; import { - receiptReactionPayload, stageReceiptReactionRemovals, - type ReceiptReactionPayload, } from "./chat-receipt-reactions.js"; import { fastResponseService, enqueueFastResponse, fastResponseSourceCurrent, fastResponseTurnQueued } from "./fast-responses.js"; import { fastResponseRequests } from "@paperclipai/db"; @@ -86,7 +103,7 @@ import { registerSlackTaskAuthority, slackRunOrigin } from "./connectors/slack-a import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js"; function githubPolicyRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } -import { githubChatManagementService } from "./chat-github-management.js"; + import { githubReviewCheckService } from "./chat-github-checks.js"; import { githubAutomaticReviewEvent, @@ -109,12 +126,12 @@ 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 { takePhotonCompanion } from "./photon/attachments.js"; + import { writePhotonCheckpoint } from "./photon/receiver.js"; import { PhotonState } from "./photon/state.js"; -import { HEIF_CONTENT_TYPES, photonHeifPreview, validatePhotonImage } from "./photon/media.js"; -import { projectSafeChatPublicationText } from "./chat-publication-projection.js"; + + import { nativePhotonInteraction, publishPhotonPrompt, type PhotonPromptReceipt, type PhotonInteractionBinding } from "./photon/interactions.js"; @@ -136,7 +153,6 @@ import { desc, eq, gt, - gte, inArray, isNotNull, isNull, @@ -202,7 +218,6 @@ import type { ChatEndpointSetupState, ChatProvider, ChatPublicationBatchStatus, - ChatPublicationSummary, ConfigureChatEndpointInput, CreateChatEndpointInput, ExternalChannelBindingSummary, @@ -219,10 +234,8 @@ import { } from "@paperclipai/shared"; import { formatAttachmentSize, - isAllowedContentType, MAX_ATTACHMENT_BYTES, normalizeContentType, - normalizeUploadAttachmentContentType, } from "../attachment-types.js"; import { isUniqueViolation } from "../db-errors.js"; import { @@ -236,24 +249,17 @@ import { import { projectTeamsFilePublication } from "./chat-teams-file-publication.js"; import type { TeamsFileConsentEvent } from "./chat-teams-file-consent.js"; import { - chatFileTransferResolutionActions, projectChatFileTransfer, projectChatPublicationBatch, } from "./chat-publication-batches.js"; import { telegramAttachmentForUpload } from "./chat-telegram-photo.js"; import { - parseTelegramCallbackReceipt, readTelegramCallbackProvenance, telegramCallbackThreadId, TELEGRAM_PRIVATE_ACTION_UNAVAILABLE, - type TelegramCallbackReceipt, } from "./chat-telegram-ephemeral.js"; -import { - hasTelegramMediaProvenance, - identifyTelegramMedia, - telegramMediaNeedsIdentification, -} from "./chat-telegram-media-intake.js"; + import { normalizeTelegramRichMessage } from "./chat-telegram-rich-intake.js"; import { isTelegramDraftStopped, @@ -262,10 +268,7 @@ import { import { parseChatWebhookPublicBaseUrl } from "../chat-webhook-public-url.js"; -import { - parseGitHubReceiptIdentity, - type GitHubReceiptIdentity, -} from "./chat-github-receipt-reactions.js"; + import { badRequest, HttpError, @@ -278,14 +281,12 @@ import { logger } from "../middleware/logger.js"; import { redactSensitiveText } from "../redaction.js"; import type { StorageService } from "../storage/types.js"; import { - DiscordAdapterCompatibilityError, type ChatSdkCallbackEvent, type ChatSdkMessageCallbackEvent, type ChatSdkMessageUpdatedCallbackEvent, type ChatSdkProvider, type ChatSdkRuntime, type DiscordGatewayCallbackEvent, - type DiscordRootMentionAdmissionEvent, type ResolvedChatSdkProviderConfig, type SlackFileUploadAcceptedReceipt, } from "./chat-sdk-runtime.js"; @@ -319,10 +320,7 @@ import { requestGitHubAppWebhookRedelivery, } from "./chat-github-webhook-config.js"; import { - GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS, - GitHubAttachmentUnavailableError, githubAttachmentLimitOmissions, - prepareGitHubPublicAttachment, restoreGitHubAttachmentLimitOmissions, } from "./chat-github-attachments.js"; import { getExternalChannelBindingSummary } from "./chat-channel-binding.js"; @@ -380,9 +378,7 @@ import { normalizeMicrosoftTeamsCredentialIds, } from "./chat-teams-credentials.js"; import { - isTeamsInlineImageContentType, prepareTeamsInlineImage, - TEAMS_INLINE_IMAGE_MAX_BYTES, } from "./chat-teams-inline-images.js"; import { shouldStreamSafePublicationText, @@ -416,44 +412,11 @@ import type { MessageDeletedEvent, ModalResponse, ModalSubmitEvent, - ReactionEvent, SlashCommandEvent, Thread, } from "chat"; import { Actions, Button, Card, CardText, LinkButton } from "chat"; -// Only these closed fields may reach Activity or a delivery-batch response. -// The resolution predicate yields one boolean, never the private envelope. -const fileTransferProjectionColumns = { - publicationId: chatTeamsFileTransfers.publicationId, - phase: chatTeamsFileTransfers.phase, - version: chatTeamsFileTransfers.version, - filename: chatTeamsFileTransfers.filename, - expiresAt: chatTeamsFileTransfers.expiresAt, - consentMessageId: chatTeamsFileTransfers.consentMessageId, - responseActivityId: chatTeamsFileTransfers.responseActivityId, - fileInfoMessageId: chatTeamsFileTransfers.fileInfoMessageId, - operatorConfirmed: sql` - coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,schema}', '') = 'paperclip.teams.file-resolution.v1' - and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,action}', '') = 'mark_delivered' - and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,fromPhase}', '') = 'file_info_unknown' - `, -}; - -function publicationSummary( - row: typeof chatPublications.$inferSelect, -): ChatPublicationSummary { - return { - id: row.id, - state: row.state, - providerUrl: row.providerUrl, - attempts: row.attempts, - redactedError: row.redactedError, - nextAttemptAt: row.nextAttemptAt?.toISOString() ?? null, - publishedAt: row.publishedAt?.toISOString() ?? null, - }; -} - const PROVIDER_LABELS: Record = { speko: "Speko", "imessage-photon": "iMessage Photon", @@ -491,207 +454,6 @@ function publicationPrecedes( ); } -function slackRequestSignatureIsValid( - request: Request, - body: string, - signingSecret: string, -): boolean { - const timestamp = request.headers.get("x-slack-request-timestamp"); - const signature = request.headers.get("x-slack-signature"); - if (!timestamp || !signature) return false; - const timestampSeconds = Number(timestamp); - if ( - !Number.isFinite(timestampSeconds) || - Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 300 - ) - return false; - const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${body}`).digest("hex")}`; - try { - return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); - } catch { - return false; - } -} - -function slackRequestWorkspaceId( - body: string, - contentType: string, -): string | null { - let payload: unknown; - try { - if (contentType.includes("application/json")) { - payload = JSON.parse(body); - } else { - const form = new URLSearchParams(body); - const interactivePayload = form.get("payload"); - payload = interactivePayload - ? JSON.parse(interactivePayload) - : Object.fromEntries(form.entries()); - } - } catch { - return null; - } - if (!payload || typeof payload !== "object") return null; - const record = payload as Record; - const nestedId = (value: unknown): string | null => { - if (!value || typeof value !== "object") return null; - const id = (value as Record).id; - return typeof id === "string" && id.length > 0 ? id : null; - }; - const stringField = (key: string): string | null => { - const value = record[key]; - return typeof value === "string" && value.length > 0 ? value : null; - }; - const authorization = Array.isArray(record.authorizations) - ? record.authorizations.find( - (value): value is Record => - Boolean(value) && typeof value === "object", - ) - : null; - const authorizationField = (key: string): string | null => { - const value = authorization?.[key]; - return typeof value === "string" && value.length > 0 ? value : null; - }; - - // A Grid event may carry both an enterprise id and the concrete workspace - // id. The bot identity is verified against auth.test's team_id, so prefer a - // team id and use the enterprise id only when Slack omitted team context. - return ( - stringField("team_id") ?? - nestedId(record.team) ?? - authorizationField("team_id") ?? - stringField("enterprise_id") ?? - nestedId(record.enterprise) ?? - authorizationField("enterprise_id") - ); -} - -function canonicalCallbackUrl(value: string): string | null { - try { - const url = new URL(value); - if (url.protocol !== "https:" && url.protocol !== "http:") return null; - url.hostname = url.hostname.replace(/\.$/, ""); - url.username = ""; - url.password = ""; - url.search = ""; - url.hash = ""; - return url.toString(); - } catch { - return null; - } -} - -// TLS terminates at a reverse proxy in many self-hosted deployments. The -// transport scheme is not evidence of URL drift; authority and path still are. -// This is diagnostic only: it does not trust forwarded headers or change auth. -function slackCallbackMatchesPublicUrl(observed: string, current: string): boolean { - const canonical = canonicalCallbackUrl(observed); - if (canonical === current) return true; - if (!canonical) return false; - const observedUrl = new URL(canonical); - const currentUrl = new URL(current); - return observedUrl.protocol === "http:" - && currentUrl.protocol === "https:" - && observedUrl.host === currentUrl.host - && observedUrl.pathname === currentUrl.pathname; -} - -type SlackCallbackInspection = { - surface: SlackCallbackSurface; - url: string; - isUrlVerification: boolean; -}; - -// Cloud records the public request host in dedicated headers so provider edges -// cannot replace it with their upstream host. Only use callback-health evidence on a -// claimed Cloud instance, after provider authentication succeeded. It must not -// change the Request passed to the adapter, signature verification, routing, -// board identity, or the configured URL used to generate callbacks. -function slackCallbackObservationUrl(request: Request): string | null { - const directUrl = canonicalCallbackUrl(request.url); - if (!directUrl || !runtimeCanonicalOrigin()) return directUrl; - const hasCloudEvidence = request.headers.has("x-paperclip-cloud-forwarded-host"); - const host = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-host" : "x-forwarded-host")?.trim(); - const protocol = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-proto" : "x-forwarded-proto")?.trim(); - if (!host || /[\s/@\\?#,]/.test(host) || (protocol !== "https" && protocol !== "http")) return directUrl; - try { - const origin = new URL(`${protocol}://${host}`); - // Accept one authority only, never credentials, paths, queries, or lists. - if (!origin.host || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) return directUrl; - const observed = new URL(directUrl); - observed.protocol = origin.protocol; - observed.host = origin.host; - observed.port = origin.port; - return canonicalCallbackUrl(observed.toString()); - } catch { - return directUrl; - } -} - -async function inspectSlackCallback( - request: Request, -): Promise { - const url = slackCallbackObservationUrl(request); - if (!url) return null; - const contentType = request.headers.get("content-type")?.toLowerCase() ?? ""; - let body: string; - try { - body = await request.text(); - } catch { - return null; - } - if (contentType.includes("application/json")) { - try { - const payload = JSON.parse(body) as { - type?: unknown; - challenge?: unknown; - }; - const isUrlVerification = - payload.type === "url_verification" && - typeof payload.challenge === "string" && - payload.challenge.length > 0; - if (isUrlVerification || payload.type === "event_callback") { - return { - surface: "events", - url, - isUrlVerification, - }; - } - } catch { - return null; - } - return null; - } - - const form = new URLSearchParams(body); - if (form.get("command")) { - return { surface: "slashCommands", url, isUrlVerification: false }; - } - const interactivePayload = form.get("payload"); - if (!interactivePayload) return null; - try { - const payload = JSON.parse(interactivePayload) as { type?: unknown }; - if ( - typeof payload.type === "string" && - [ - "block_actions", - "block_suggestion", - "dialog_submission", - "message_action", - "shortcut", - "view_closed", - "view_submission", - "workflow_step_edit", - ].includes(payload.type) - ) { - return { surface: "interactivity", url, isUrlVerification: false }; - } - } catch { - return null; - } - return null; -} - 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 }, @@ -838,13 +600,6 @@ const SUPPLIED_CREDENTIAL_KEYS: Record = { const MAX_ERROR_TEXT = 2_000; const DELIVERY_PROCESSING_STALE_MS = 60_000; -// Reactions can beat the transaction that persists a just-sent provider -// message link. Keep that narrow gap durable, but never retain an unbound -// reaction indefinitely or let it enter the ordinary inbound-message FIFO. -const REACTION_LINK_RETRY_DELAY_MS = 250; -const REACTION_LINK_MAX_DELAY_MS = 5_000; -const REACTION_LINK_MAX_ATTEMPTS = 20; -const REACTION_LINK_MAX_AGE_MS = 2 * 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 @@ -884,17 +639,6 @@ const INGRESS_REORDER_WINDOW_MS: Partial> = { telegram: 750, }; type ResourceRow = typeof chatEndpointResources.$inferSelect; -type SlackCallbackSurface = keyof ChatEndpointCallbackSurfaces; -type SlackCallbackObservation = { url: string; observedAt: string }; -type InternalSetupState = ChatEndpointSetupState & { - photonIntakeAfter?: string; - runtimeGeneration?: number; - /** Binds pre-install URL verification to the signing secret later configured. */ - slackVerificationSigningFingerprint?: string; - slackCallbackSurfaces?: Partial< - Record - >; -}; type VerifiedProviderIdentity = { githubOwnerType?: "personal" | "organization"; githubOwnerLogin?: string; @@ -1051,130 +795,6 @@ type LiveInboundMessage = { thread: Thread; trigger: ChatSdkMessageCallbackEvent["trigger"]; }; -type ProviderEffectTarget = { - post: Thread["post"]; - postEphemeral?: Thread["postEphemeral"]; -}; -type ProviderEffectPayload = { - version: 1; - effect: "thread_message" | "ephemeral_message" | "telegram_callback_notice"; - telegramCallback?: TelegramCallbackReceipt; - telegramAuthorizationSha256?: string; - authorizationMode?: "principal" | "safe_notice"; - threadId: string; - userId?: string; - text: string; - fallbackText?: string; - settleDelivery: boolean; - completeConversationId?: string; - resourceId?: string; - runtimeGeneration: number; - credentialFingerprint: string; -}; - -function isSlackReceiptReactionAlreadyApplied(error: unknown): boolean { - const pending: Array<{ depth: number; value: unknown }> = [ - { depth: 0, value: error }, - ]; - const seen = new Set(); - 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; - data?: { error?: unknown }; - original?: unknown; - originalError?: unknown; - }; - if ( - record.code === "already_reacted" || - record.data?.error === "already_reacted" - ) { - return true; - } - for (const value of [record.cause, record.original, record.originalError]) { - pending.push({ depth: current.depth + 1, value }); - } - } - return false; -} - -function providerEffectPayload( - payload: Record, -): ProviderEffectPayload | null { - if ( - payload.version !== 1 || - (payload.effect !== "thread_message" && - payload.effect !== "ephemeral_message" && - payload.effect !== "telegram_callback_notice") || - typeof payload.threadId !== "string" || - !payload.threadId || - typeof payload.text !== "string" || - !payload.text || - typeof payload.settleDelivery !== "boolean" || - typeof payload.runtimeGeneration !== "number" || - !Number.isSafeInteger(payload.runtimeGeneration) || - typeof payload.credentialFingerprint !== "string" - ) { - return null; - } - if ( - payload.effect === "ephemeral_message" && - (typeof payload.userId !== "string" || !payload.userId) - ) { - return null; - } - for (const key of [ - "authorizationMode", - "userId", - "fallbackText", - "completeConversationId", - "resourceId", - ] as const) { - const value = payload[key]; - if (value !== undefined && (typeof value !== "string" || !value)) { - return null; - } - } - if ( - payload.authorizationMode !== undefined && - payload.authorizationMode !== "principal" && - payload.authorizationMode !== "safe_notice" - ) { - return null; - } - if (payload.effect === "telegram_callback_notice") { - const receipt = parseTelegramCallbackReceipt(payload.telegramCallback); - if ( - !receipt || - payload.authorizationMode !== "safe_notice" || - payload.text !== TELEGRAM_PRIVATE_ACTION_UNAVAILABLE || - payload.threadId !== telegramCallbackThreadId(receipt) || - payload.userId !== receipt.receiverUserId || - payload.settleDelivery !== false || - payload.fallbackText !== undefined || - payload.completeConversationId !== undefined || - typeof payload.telegramAuthorizationSha256 !== "string" || - !/^[a-f0-9]{64}$/.test(payload.telegramAuthorizationSha256) - ) - return null; - } else if ( - payload.telegramCallback !== undefined || - payload.telegramAuthorizationSha256 !== undefined - ) - return null; - return payload as ProviderEffectPayload; -} export interface ChatChannelServiceOptions { /** @@ -1297,6 +917,7 @@ export interface ChatChannelServiceOptions { claimId: string; }) => Promise; renderSlackAvatar?: (request: AgentAvatarRequest) => Promise; + slackManaged?: import("./connectors/slack/setup/registration.js").SlackSetupOptions["managed"]; storage?: StorageService; } @@ -1485,15 +1106,6 @@ function safeTitle(text: string, fallback: string): string { return (line || fallback).slice(0, 160); } -function slackThreadChannelId(threadId: string): string | null { - if (!threadId.startsWith("slack:")) return null; - const channelAndThread = threadId.slice("slack:".length); - const separator = channelAndThread.indexOf(":"); - const channelId = - separator === -1 ? channelAndThread : channelAndThread.slice(0, separator); - return channelId || null; -} - function actionThreadMatchesConversation( provider: ChatProvider, actionThreadId: string, @@ -1510,24 +1122,6 @@ function actionThreadMatchesConversation( ); } -function slackTaskStartActivityStatus(action: { - status: string; - updatedAt: Date; -}): string { - const age = Date.now() - action.updatedAt.getTime(); - if (action.status === "validating" && age >= SLACK_COMMAND_POST_STALE_MS) { - return "queued"; - } - if ( - (action.status === "received" && age >= SLACK_COMMAND_POST_STALE_MS) || - (action.status === "resolving" && - age >= SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS) - ) { - return "delivery_unknown"; - } - return action.status; -} - function sanitizeFilename(value: string | undefined): string | null { if (!value) return null; const leaf = value.replaceAll("\\", "/").split("/").pop()?.trim(); @@ -1682,6 +1276,7 @@ function providerSetupState( 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; @@ -1816,163 +1411,6 @@ export function createChatSdkStatePersistence(db: Db): ChatSdkStatePersistence { }; } -const OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS = 10_000; - -class OutboundAttachmentHydrationError extends Error { - readonly code = "CHAT_ATTACHMENT_HYDRATION_FAILED"; - - constructor(message: string, cause?: unknown) { - super(message, cause === undefined ? undefined : { cause }); - this.name = "OutboundAttachmentHydrationError"; - } -} - -class OutboundAttachmentValidationError extends Error { - readonly code = "CHAT_ATTACHMENT_VALIDATION_FAILED"; - - constructor(message: string) { - super(message); - this.name = "OutboundAttachmentValidationError"; - } -} - -function isOutboundAttachmentHydrationError( - error: unknown, -): error is OutboundAttachmentHydrationError { - return error instanceof OutboundAttachmentHydrationError; -} - -function isOutboundAttachmentValidationError( - error: unknown, -): error is OutboundAttachmentValidationError { - return error instanceof OutboundAttachmentValidationError; -} - -/** - * Read one already-authorized attachment before any provider transport starts. - * The database digest, rather than mutable object-store metadata, is the - * integrity boundary. Both acquisition and streaming are bounded so a stuck - * storage backend cannot hold the publication lane indefinitely. - */ -export async function hydrateOutboundAttachment(input: { - storage: StorageService; - companyId: string; - objectKey: string; - byteSize: number; - sha256: string; - filename: string; - mimeType: string; - timeoutMs?: number; -}): Promise { - const timeoutMs = - typeof input.timeoutMs === "number" && - Number.isFinite(input.timeoutMs) && - input.timeoutMs > 0 - ? input.timeoutMs - : OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS; - if ( - !Number.isSafeInteger(input.byteSize) || - input.byteSize <= 0 || - input.byteSize > MAX_ATTACHMENT_BYTES || - !/^[0-9a-f]{64}$/iu.test(input.sha256) - ) { - throw new OutboundAttachmentHydrationError( - "Chat publication attachment metadata is invalid", - ); - } - const acquisitionTimeoutError = new OutboundAttachmentHydrationError( - "Chat publication attachment storage read timed out", - ); - let acquisitionTimedOut = false; - let rejectAcquisition!: (error: Error) => void; - const acquisitionTimeout = new Promise((_resolve, reject) => { - rejectAcquisition = reject; - }); - const acquisitionTimer = setTimeout(() => { - acquisitionTimedOut = true; - rejectAcquisition(acquisitionTimeoutError); - }, timeoutMs); - acquisitionTimer.unref?.(); - const objectPromise = Promise.resolve() - .then(() => input.storage.getObject(input.companyId, input.objectKey)) - .then((object) => { - if (acquisitionTimedOut) object.stream.destroy(); - return object; - }) - .catch((error) => { - throw isOutboundAttachmentHydrationError(error) - ? error - : new OutboundAttachmentHydrationError( - "Chat publication attachment storage read failed", - error, - ); - }); - let object: Awaited>; - try { - object = await Promise.race([objectPromise, acquisitionTimeout]); - } finally { - clearTimeout(acquisitionTimer); - } - - if ( - object.contentLength !== undefined && - object.contentLength !== input.byteSize - ) { - object.stream.destroy(); - throw new OutboundAttachmentHydrationError( - "Chat publication attachment size changed after registration", - ); - } - - const streamTimeoutError = new OutboundAttachmentHydrationError( - "Chat publication attachment storage read timed out", - ); - const streamTimer = setTimeout(() => { - object.stream.destroy(streamTimeoutError); - }, timeoutMs); - streamTimer.unref?.(); - const chunks: Buffer[] = []; - const digest = createHash("sha256"); - let bytes = 0; - try { - for await (const chunk of object.stream) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - bytes += buffer.length; - if (bytes > input.byteSize || bytes > MAX_ATTACHMENT_BYTES) { - object.stream.destroy(); - throw new OutboundAttachmentHydrationError( - "Chat publication attachment exceeded its streaming byte limit", - ); - } - digest.update(buffer); - chunks.push(buffer); - } - } catch (error) { - throw isOutboundAttachmentHydrationError(error) - ? error - : new OutboundAttachmentHydrationError( - "Chat publication attachment storage read failed", - error, - ); - } finally { - clearTimeout(streamTimer); - } - - const actualDigest = digest.digest("hex"); - if (bytes !== input.byteSize || actualDigest !== input.sha256.toLowerCase()) { - if (!object.stream.destroyed) object.stream.destroy(); - throw new OutboundAttachmentHydrationError( - "Chat publication attachment integrity changed after registration", - ); - } - - return { - data: Buffer.concat(chunks, bytes), - filename: input.filename, - mimeType: input.mimeType, - }; -} - export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const runtime = options.runtime ?? createChatRuntime(); const githubManualMessages = new WeakMap(); @@ -2004,7 +1442,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { // An explicit webhook ingress remains separate from board/identity links. const getPublicBaseUrl = () => runtimeCanonicalOrigin() ?? configuredPublicBaseUrl; const { listPrincipals, createLinkIntent, previewIdentityLink, requestIdentityAccess, confirmIdentityLink, revokeLink } = createChatIdentityLinks(db, { - endpointRecord, getPublicBaseUrl, stageProviderEffect, scheduleProviderEffect, + endpointRecord, getPublicBaseUrl, stageProviderEffect: (...args) => stageProviderEffect(...args), scheduleProviderEffect, confirmationRuntimeFor: async (endpoint) => runtimeContexts.get((await runtimeFor(endpoint)) as object), }); // Task links must validate the original configured URL before normalization @@ -2454,67 +1892,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - async function addReceiptReaction(input: { - deliveryId: string; - endpoint: EndpointRow; - message: Message; - runtimeContext?: LifecycleRuntimeFence; - thread: Thread; - }): Promise { - // GitHub acknowledges through one editable response comment. Continue to - // drain legacy eyes removals, but never create new eyes for these requests. - if (input.endpoint.provider === "github") return; - const record = await endpointRecord(input.endpoint.id); - if (!record) return; - // A live SDK object remains bound to the credentials that authenticated - // its callback. Preserve that original fence rather than sampling the - // endpoint again after a rotation and accidentally blessing an obsolete - // adapter with the new generation. - const context = input.runtimeContext ?? runtimeContextForRecord(record); - const [inserted] = await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "receipt_reaction"); - return tx - .insert(chatActions) - .values({ - companyId: input.endpoint.companyId, - endpointId: input.endpoint.id, - deliveryId: input.deliveryId, - kind: "receipt_reaction", - providerActionId: `receipt_reaction:${input.deliveryId}`, - payload: { - version: 1, - operation: "add", - threadId: input.thread.id, - messageId: input.message.id, - reaction: "eyes", - runtimeGeneration: context.generation, - credentialFingerprint: context.credentialFingerprint, - } satisfies ReceiptReactionPayload, - status: "received", - }) - .onConflictDoNothing() - .returning({ id: chatActions.id }); - }); - const actionId = - inserted?.id ?? - (await db - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.endpoint.id), - eq( - chatActions.providerActionId, - `receipt_reaction:${input.deliveryId}`, - ), - ), - ) - .then((rows) => rows[0]?.id ?? null)); - if (!actionId) return; - await options.receiptReactionTransportBarrier?.(); - await processReceiptReaction(actionId); - } - function conversationDrainKey(endpointId: string, threadId: string) { return `${endpointId}:${createHash("sha256").update(durableExternalThreadIdentity(threadId)).digest("hex")}`; } @@ -2583,506 +1960,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .then((rows) => rows[0] ?? null); } - async function processReceiptReaction(actionId: string): Promise { - const initialAction = await db - .select({ endpointId: chatActions.endpointId }) - .from(chatActions) - .where( - and( - eq(chatActions.id, actionId), - eq(chatActions.kind, "receipt_reaction"), - ), - ) - .then((rows) => rows[0] ?? null); - if (!initialAction) return; - const initialEndpoint = await endpointRecord(initialAction.endpointId); - if (!initialEndpoint) return; - // Receipt reactions are GitHub repository mutations too. Serialize their - // final runtime-fence check and provider call with reconnect, secret - // rotation, pause, and removal so credentials cannot become obsolete - // between authorization and transport. - await withCredentialMutationLease(initialEndpoint.endpoint, (lease) => - processReceiptReactionWithCredentialLease(actionId, lease), - ); - } - - async function processReceiptReactionWithCredentialLease( - actionId: string, - credentialLease: CredentialMutationLeaseGuard, - ): Promise { - let action = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.id, actionId), - eq(chatActions.kind, "receipt_reaction"), - ), - ) - .then((rows) => rows[0] ?? null); - if (!action || ["processed", "cancelled"].includes(action.status)) return; - const payload = receiptReactionPayload(action.payload); - if (!payload) { - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "receipt_reaction"); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { - code: "receipt_reaction_payload_invalid", - retryable: false, - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, action.id)); - }); - return; - } - if (payload.operation === "add" && action.deliveryId) { - const terminalRemoval = await db - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, action.endpointId), - eq( - chatActions.providerActionId, - `receipt_reaction_remove:${action.deliveryId}`, - ), - ), - ) - .then((rows) => rows[0] ?? null); - if (terminalRemoval) { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - ...(typeof action!.result?.attempts === "number" - ? { attempts: action!.result.attempts } - : {}), - code: "receipt_reaction_superseded_by_terminal_publication", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.status, action!.status), - ), - ); - await credentialLease.assertOwned(tx); - }); - return; - } - } - if (action.status === "processing") { - if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS)) - return; - // Adding or removing the same reaction is idempotent. A worker - // interruption can be retried safely without an ambiguous - // duplicate-message boundary. - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "receipt_reaction"); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { - ...(action.result ?? {}), - retryable: true, - retryAt: new Date().toISOString(), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - eq(chatActions.updatedAt, action.updatedAt), - ), - ); - }); - action = await db - .select() - .from(chatActions) - .where(eq(chatActions.id, action.id)) - .then((rows) => rows[0] ?? null); - if (!action) return; - } - if (action.status === "failed") { - if (action.result?.retryable !== true) return; - const retryAt = - typeof action.result.retryAt === "string" - ? new Date(action.result.retryAt) - : null; - if (retryAt && retryAt > new Date()) return; - } else if (action.status !== "received") { - return; - } - const attempt = - (typeof action.result?.attempts === "number" ? action.result.attempts : 0) + - 1; - const retainedGitHubReceipt = parseGitHubReceiptIdentity( - action.result?.githubReceipt, - ); - const [claimed] = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - const rows = await tx - .update(chatActions) - .set({ - status: "processing", - result: { - attempts: attempt, - ...(retainedGitHubReceipt - ? { githubReceipt: retainedGitHubReceipt } - : {}), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.status, action!.status), - ), - ) - .returning(); - await credentialLease.assertOwned(tx); - return rows; - }); - if (!claimed) return; - - let reactionProvider: ChatProvider | null = null; - let githubReceipt: GitHubReceiptIdentity | null = retainedGitHubReceipt; - try { - const record = await endpointRecord(action.endpointId); - if ( - !record || - !["verifying", "active"].includes(record.endpoint.status) || - runtimeGeneration(record.endpoint.setup) !== payload.runtimeGeneration || - credentialFingerprint(record.credentialSecretRefs) !== - payload.credentialFingerprint - ) { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - attempts: attempt, - code: "receipt_reaction_runtime_superseded", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ); - await credentialLease.assertOwned(tx); - }); - return; - } - reactionProvider = record.endpoint.provider; - // Resolve the adapter only after acquiring the credential lease and - // proving the action's durable generation/fingerprint is still current. - // Never reuse the live callback's adapter: it may hold an installation - // token minted by a superseded private key. - const endpointRuntime = await runtimeFor(record.endpoint); - const adapter = endpointRuntime.thread(payload.threadId).adapter; - await credentialLease.assertOwned(); - if (record.endpoint.provider === "slack") { - await endpointRuntime.applySlackReceiptReaction(payload, fetchImpl); - } else if (record.endpoint.provider === "github") { - const assertCurrent = async () => { - await credentialLease.assertOwned(); - const [current] = await db - .select({ endpoint: chatEndpoints, connection: toolConnections }) - .from(chatEndpoints) - .innerJoin( - toolConnections, - and( - eq(toolConnections.id, chatEndpoints.connectionId), - eq(toolConnections.companyId, chatEndpoints.companyId), - ), - ) - .where( - and( - eq(chatEndpoints.id, action!.endpointId), - eq(chatEndpoints.companyId, action!.companyId), - ), - ) - .limit(1); - if ( - !current || - !["active", "verifying"].includes(current.endpoint.status) || - !current.connection.enabled || - current.connection.status !== "active" || - runtimeGeneration(current.endpoint.setup) !== - payload.runtimeGeneration || - credentialFingerprint(current.connection.credentialSecretRefs) !== - payload.credentialFingerprint || - runtime.get(action!.endpointId) !== endpointRuntime - ) - throw new Error("GitHub receipt authority is no longer current"); - }; - const applied = await endpointRuntime.applyGitHubReceiptReaction( - { ...payload, ...(githubReceipt ? { githubReceipt } : {}) }, - assertCurrent, - fetchImpl, - ); - githubReceipt = parseGitHubReceiptIdentity(applied); - if ( - !githubReceipt || - (payload.operation === "add" && !githubReceipt.reactionId) - ) - throw new Error("GitHub receipt identity was not confirmed"); - } else if (payload.operation === "remove") { - await adapter.removeReaction( - payload.threadId, - payload.messageId, - payload.reaction, - ); - } else { - await adapter.addReaction( - payload.threadId, - payload.messageId, - payload.reaction, - ); - } - // The provider may already have accepted the idempotent reaction. Fence - // the local success receipt so a reclaimed lease cannot report success - // for an obsolete runtime. - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - const processed = await tx - .update(chatActions) - .set({ - status: "processed", - result: { - attempts: attempt, - ...(githubReceipt ? { githubReceipt } : {}), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (!processed.length) { - throw new Error( - "Receipt-reaction ownership changed before success settlement", - ); - } - if (action.deliveryId) { - await tx - .update(chatDeliveries) - .set({ redactedError: null, updatedAt: new Date() }) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.state, "processed"), - like(chatDeliveries.redactedError, "Receipt reaction %"), - ), - ); - } - await credentialLease.assertOwned(tx); - }); - } catch (error) { - if ( - payload.operation === "add" && - reactionProvider === "slack" && - isSlackReceiptReactionAlreadyApplied(error) - ) { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - const processed = await tx - .update(chatActions) - .set({ - status: "processed", - result: { - attempts: attempt, - code: "receipt_reaction_already_applied", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (!processed.length) { - throw new Error( - "Receipt-reaction ownership changed before idempotent settlement", - ); - } - if (action.deliveryId) { - await tx - .update(chatDeliveries) - .set({ redactedError: null, updatedAt: new Date() }) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.state, "processed"), - like(chatDeliveries.redactedError, "Receipt reaction %"), - ), - ); - } - await credentialLease.assertOwned(tx); - }); - return; - } - const disposition = classifyChatPublicationError(error, attempt); - const retryable = - disposition.kind === "retry" || - (disposition.kind === "delivery_unknown" && attempt < 3); - const retryAfterMs = - disposition.kind === "retry" - ? disposition.retryAfterMs - : 100 * 2 ** Math.max(0, attempt - 1); - const operationLabel = - payload.operation === "remove" ? " reaction removal" : " reaction"; - const diagnostic = - `Receipt${operationLabel} ${retryable ? "deferred" : "failed"} after ${attempt} attempt${attempt === 1 ? "" : "s"} (${disposition.kind}): ${redactError(error)}`.slice( - 0, - MAX_ERROR_TEXT, - ); - const retryAt = retryable - ? new Date(Date.now() + retryAfterMs).toISOString() - : null; - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await notifyChatActionWork(tx, "receipt_reaction"); - const [failed] = await tx - .update(chatActions) - .set({ - status: "failed", - result: { - attempts: attempt, - ...(githubReceipt ? { githubReceipt } : {}), - code: - payload.operation === "remove" - ? `receipt_reaction_removal_${disposition.kind}` - : `receipt_reaction_${disposition.kind}`, - redactedError: diagnostic, - retryable, - retryAt, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (failed && action.deliveryId) { - await tx - .update(chatDeliveries) - .set({ redactedError: diagnostic, updatedAt: new Date() }) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.state, "processed"), - ), - ); - } - await credentialLease.assertOwned(tx); - }); - logger.warn( - { - endpointId: action.endpointId, - deliveryId: action.deliveryId, - attempts: attempt, - disposition: disposition.kind, - operation: payload.operation, - retryAt, - }, - "external chat receipt reaction deferred", - ); - } - } - - async function processPendingReceiptReactions( - limit = 25, - onlyActionId?: string, - ) { - const actions = await db.select({ id: chatActions.id }).from(chatActions) - .where(and(dueChatAction("receipt_reaction"), onlyActionId ? eq(chatActions.id, onlyActionId) : undefined)) - .orderBy(asc(chatActions.createdAt)).limit(limit); - for (const action of actions) await processReceiptReaction(action.id); - return actions.length; - } - - async function stageProviderEffect( - database: DbOrTransaction, - input: { - endpoint: EndpointRow; - deliveryId?: string | null; - conversationId?: string | null; - principalId?: string | null; - providerActionId: string; - payload: Omit< - ProviderEffectPayload, - "runtimeGeneration" | "credentialFingerprint" - >; - runtimeContext: LifecycleRuntimeFence; - }, - ) { - await notifyChatActionWork(database, "provider_effect"); - const [inserted] = await database - .insert(chatActions) - .values({ - companyId: input.endpoint.companyId, - endpointId: input.endpoint.id, - deliveryId: input.deliveryId ?? null, - conversationId: input.conversationId ?? null, - principalId: input.principalId ?? null, - kind: "provider_effect", - providerActionId: input.providerActionId, - payload: { - ...input.payload, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - }, - status: "received", - }) - .onConflictDoNothing() - .returning(); - if (inserted) return inserted; - return database - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.endpoint.id), - eq(chatActions.providerActionId, input.providerActionId), - eq(chatActions.kind, "provider_effect"), - ), - ) - .then((rows) => rows[0] ?? null); - } - async function stageAuthorizedTaskControlPublication( tx: DbOrTransaction, input: { @@ -3140,809 +2017,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return publication; } - function providerEffectDisposition( - error: unknown, - attempt: number, - providerEffectCompleted: boolean, - ) { - if (!providerEffectCompleted) - return classifyChatPublicationError(error, attempt); - return { - kind: "delivery_unknown" as const, - reason: - "Provider effect completed, but Paperclip could not confirm its durable result", - }; - } - - async function finalizeProviderEffectFailure(input: { - action: typeof chatActions.$inferSelect; - endpoint: EndpointRow; - payload: ProviderEffectPayload; - disposition: ReturnType; - attempt: number; - }): Promise { - const failure = redactSensitiveText(input.disposition.reason).slice( - 0, - MAX_ERROR_TEXT, - ); - const retryable = input.disposition.kind === "retry"; - const retryAt = - input.disposition.kind === "retry" - ? new Date(Date.now() + input.disposition.retryAfterMs) - : null; - let invalidateCurrentRuntime = false; - const finalized = await db.transaction(async (tx) => { - // Admission locks the endpoint before its delivery. Keep that order - // when failure settlement also needs to change endpoint/resource health. - const currentEndpoint = - input.disposition.kind === "endpoint_attention" || - input.disposition.kind === "resource_unavailable" - ? await runtimeCallbackEndpoint( - tx, - input.endpoint.id, - { - generation: input.payload.runtimeGeneration, - credentialFingerprint: input.payload.credentialFingerprint, - }, - ["verifying", "active"], - ) - : null; - await notifyChatActionWork(tx, "provider_effect"); - const [ownedAction] = await tx - .update(chatActions) - .set({ - status: - input.disposition.kind === "delivery_unknown" - ? "delivery_unknown" - : "failed", - result: { - code: `provider_effect_${input.disposition.kind}`, - attempts: input.attempt, - redactedError: failure, - retryable, - retryAt: retryAt?.toISOString() ?? null, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, input.action.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${input.attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (!ownedAction) return false; - if (input.payload.settleDelivery && input.action.deliveryId) { - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: retryable ? "retry" : "failed", - nextAttemptAt: retryAt, - redactedError: - input.disposition.kind === "delivery_unknown" - ? `Provider response is unconfirmed: ${failure}`.slice( - 0, - MAX_ERROR_TEXT, - ) - : failure, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, input.action.deliveryId), - eq(chatDeliveries.state, "processing"), - ), - ); - } - if (input.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 ( - input.disposition.kind === "resource_unavailable" && - input.payload.resourceId && - currentEndpoint - ) { - await tx - .update(chatEndpointResources) - .set({ availability: "unavailable", updatedAt: new Date() }) - .where( - and( - eq(chatEndpointResources.id, input.payload.resourceId), - eq(chatEndpointResources.endpointId, currentEndpoint.id), - ), - ); - } - return true; - }); - if (finalized && invalidateCurrentRuntime) { - await invalidateRuntime(input.endpoint.id).catch(() => undefined); - } - return finalized; - } - - async function quarantineStaleProviderEffect( - action: typeof chatActions.$inferSelect, - ) { - const payload = providerEffectPayload(action.payload); - const failure = - "Provider delivery could not be confirmed after the worker stopped. The effect will not be replayed automatically."; - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "provider_effect"); - const [quarantined] = await tx - .update(chatActions) - .set({ - status: "delivery_unknown", - result: { - code: "provider_effect_delivery_unknown", - redactedError: failure, - retryable: false, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - eq(chatActions.updatedAt, action.updatedAt), - sql`(${chatActions.result}->>'attempts')::int = ${ - typeof action.result?.attempts === "number" - ? action.result.attempts - : -1 - }`, - lte( - chatActions.updatedAt, - new Date(Date.now() - PROVIDER_EFFECT_STALE_MS), - ), - ), - ) - .returning({ id: chatActions.id }); - if (quarantined && payload?.settleDelivery && action.deliveryId) { - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "failed", - nextAttemptAt: null, - redactedError: failure, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.state, "processing"), - ), - ); - } - }); - } - - function providerEffectThreadResourceId( - provider: ChatProvider, - threadId: string, - ): string | null { - if (provider === "slack") { - return ( - slackThreadChannelId(threadId) ?? - (!threadId.includes(":") ? threadId : null) - ); - } - if (provider === "telegram") { - return /^telegram:([^:]+)/.exec(threadId)?.[1] ?? null; - } - if (provider === "github") { - return /^github:([^:]+)/.exec(threadId)?.[1]?.toLowerCase() ?? null; - } - if (provider === "discord") { - return /^discord:[^:]+:([^:]+)/.exec(threadId)?.[1] ?? null; - } - const conversationId = teamsConversationId(threadId); - return conversationId ? baseTeamsConversationId(conversationId) : null; - } - - // This is permission to return one fixed, neutral callback denial, not - // permission to execute the denied action or publish to the conversation. - // Retain the initial identity-policy snapshot so queued notices cannot cross - // a subsequent actor/link/reach change. No provider I/O occurs under locks. - async function lockTelegramCallbackNoticeAuthority( - tx: DbTransaction, - endpoint: EndpointRow, - receipt: TelegramCallbackReceipt, - principalId: string | null, - ): Promise { - if ( - endpoint.provider !== "telegram" || - endpoint.status !== "active" || - endpoint.companyId !== receipt.companyId || - endpoint.id !== receipt.endpointId || - endpoint.botExternalId !== receipt.botUserId || - !endpoint.providerAccountId || - !principalId || - Date.now() < receipt.receivedAtMs || - Date.now() >= receipt.deadlineAtMs - ) - return null; - const resource = - receipt.chatType === "private" - ? null - : await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.providerResourceId, receipt.chatId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - receipt.chatType === "private" - ? !endpoint.allowDirectMessages - : !resource || !nonDirectDestinationAllowed(endpoint, resource) - ) - return null; - const principal = await tx - .select() - .from(chatExternalPrincipals) - .where( - and( - eq(chatExternalPrincipals.companyId, endpoint.companyId), - eq(chatExternalPrincipals.provider, endpoint.provider), - eq( - chatExternalPrincipals.providerAccountId, - endpoint.providerAccountId, - ), - eq(chatExternalPrincipals.id, principalId), - eq(chatExternalPrincipals.externalId, receipt.receiverUserId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!principal || principal.kind !== "user" || principal.isBot) return null; - const authorization = await lockCurrentPrincipalAuthorization( - tx, - endpoint, - principal.id, - ); - 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, principal.id), - ), - ) - .then((rows) => rows[0] ?? null); - return createHash("sha256") - .update( - JSON.stringify([ - endpoint.companyId, - endpoint.id, - receipt.botUserId, - resource?.id ?? null, - principal.id, - principal.externalId, - link, - authorization, - endpoint.allowUnlinkedPeople, - endpoint.sponsorUserId, - ]), - ) - .digest("hex"); - } - - async function lockCurrentProviderEffectAuthorization( - tx: DbTransaction, - action: typeof chatActions.$inferSelect, - endpoint: EndpointRow, - payload: ProviderEffectPayload, - ): Promise { - if (payload.effect === "telegram_callback_notice") { - const receipt = parseTelegramCallbackReceipt(payload.telegramCallback); - if (!receipt || !action.deliveryId) return false; - const delivery = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.companyId, action.companyId), - eq(chatDeliveries.endpointId, action.endpointId), - eq(chatDeliveries.state, "filtered"), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const retained = - delivery && - parseTelegramCallbackReceipt(delivery.normalizedEvent.telegramCallback); - if ( - !delivery || - delivery.principalId !== action.principalId || - retained?.sourceSha256 !== receipt.sourceSha256 || - delivery.normalizedEvent.telegramAuthorizationSha256 !== - payload.telegramAuthorizationSha256 - ) - return false; - return ( - (await lockTelegramCallbackNoticeAuthority( - tx, - endpoint, - receipt, - action.principalId, - )) === payload.telegramAuthorizationSha256 - ); - } - const conversation = action.conversationId - ? await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, action.conversationId), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - if (action.conversationId && !conversation) return false; - const inferredResourceId = providerEffectThreadResourceId( - endpoint.provider, - payload.threadId, - ); - const resource = await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - payload.resourceId || conversation?.resourceId - ? eq( - chatEndpointResources.id, - payload.resourceId ?? conversation!.resourceId!, - ) - : inferredResourceId - ? eq(chatEndpointResources.providerResourceId, inferredResourceId) - : sql`false`, - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const slackChannelId = - endpoint.provider === "slack" - ? slackThreadChannelId(payload.threadId) - : null; - const isDirectMessage = - conversation?.isDirectMessage === true || - resource?.type === "direct_message" || - Boolean(slackChannelId && /^D[A-Z0-9]+$/i.test(slackChannelId)); - const destinationAllowed = isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource); - if (payload.authorizationMode === "safe_notice") return true; - if (!destinationAllowed) return false; - if (!action.principalId) return false; - return ( - await lockCurrentPrincipalAuthorization(tx, endpoint, action.principalId) - ).allowed; - } - - async function cancelUnauthorizedProviderEffect( - tx: DbTransaction, - action: typeof chatActions.$inferSelect, - attempt: number, - ): Promise { - await notifyChatActionWork(tx, "provider_effect"); - const [cancelled] = await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - attempts: attempt, - code: "provider_effect_no_longer_authorized", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (!cancelled) return false; - if (action.deliveryId) { - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "filtered", - processedAt: new Date(), - nextAttemptAt: null, - redactedError: - "Provider reply was suppressed because current chat authorization changed", - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.state, "processing"), - ), - ); - } - return true; - } - - async function processProviderEffect( - actionId: string, - liveTarget?: ProviderEffectTarget, - ): Promise<"processed" | "pending" | "failed" | "delivery_unknown"> { - const initial = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.id, actionId), - eq(chatActions.kind, "provider_effect"), - ), - ) - .then((rows) => rows[0] ?? null); - if (!initial) return "failed"; - const initialEndpoint = await endpointRecord(initial.endpointId); - if (!initialEndpoint) return "failed"; - - const processClaimedEffect = async () => { - let action = await db - .select() - .from(chatActions) - .where(eq(chatActions.id, actionId)) - .then((rows) => rows[0] ?? null); - if (!action) return "failed"; - const payload = providerEffectPayload(action.payload); - if (!payload) { - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "provider_effect"); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { code: "provider_effect_payload_invalid" }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, action.id)); - }); - return "failed"; - } - if (action.status === "processed") return "processed"; - if (action.status === "delivery_unknown") return "delivery_unknown"; - if (action.status === "processing") { - if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS)) - return "pending"; - await quarantineStaleProviderEffect(action); - return "delivery_unknown"; - } - if (action.status === "failed") { - if (action.result?.retryable !== true) return "failed"; - const retryAt = - typeof action.result.retryAt === "string" - ? new Date(action.result.retryAt) - : null; - if (retryAt && retryAt > new Date()) return "pending"; - } else if (action.status !== "received") { - return "failed"; - } - - const attempt = - (typeof action.result?.attempts === "number" - ? action.result.attempts - : 0) + 1; - const record = await endpointRecord(action.endpointId); - let providerEffectCompleted = false; - try { - if (!record) { - throw Object.assign( - new Error("Chat endpoint is unavailable for this provider effect"), - { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, - ); - } - return await withCredentialMutationLease( - record.endpoint, - async (credentialLease) => { - const claim = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const currentAction = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.kind, "provider_effect"), - eq(chatActions.status, action!.status), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!currentAction) return null; - const currentEndpoint = await runtimeCallbackEndpoint( - tx, - currentAction.endpointId, - { - generation: payload.runtimeGeneration, - credentialFingerprint: payload.credentialFingerprint, - }, - ["verifying", "active"], - ); - const authorized = currentEndpoint - ? await lockCurrentProviderEffectAuthorization( - tx, - currentAction, - currentEndpoint, - payload, - ) - : false; - await notifyChatActionWork(tx, "provider_effect"); - const [transportClaim] = await tx - .update(chatActions) - .set({ - status: "processing", - result: { attempts: attempt }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, currentAction.id), - eq(chatActions.status, currentAction.status), - ), - ) - .returning(); - if (!transportClaim) return null; - if (!currentEndpoint || !authorized) { - const cancelled = await cancelUnauthorizedProviderEffect( - tx, - transportClaim, - attempt, - ); - if (!cancelled) { - throw new Error( - "Provider effect ownership changed before authorization cancellation", - ); - } - return { outcome: "cancelled" as const }; - } - await credentialLease.assertOwned(tx); - return { - action: transportClaim, - endpoint: currentEndpoint, - outcome: "claimed" as const, - }; - }); - if (!claim) return "pending"; - if (claim.outcome === "cancelled") return "failed"; - action = claim.action; - - // `processing` is the durable, single-owner transport claim and - // authorization linearization point. Its transaction has committed - // before provider I/O begins. The renewable credential lease keeps - // runtime identity stable without holding database row locks. - let sent: { id: string; threadId: string } | null = null; - if (payload.effect === "telegram_callback_notice") { - sent = await ( - await runtimeFor(claim.endpoint) - ).sendTelegramCallbackNotice( - payload.telegramCallback!, - payload.text, - ); - } else { - const target = - liveTarget ?? - (await runtimeFor(claim.endpoint)).thread(payload.threadId); - if (payload.effect === "ephemeral_message") { - if (!target.postEphemeral) { - if (!payload.fallbackText) { - throw Object.assign( - new Error("Provider does not support ephemeral messages"), - { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, - ); - } - sent = await target.post(payload.fallbackText); - } else { - try { - sent = await target.postEphemeral( - payload.userId!, - payload.text, - { fallbackToDM: false }, - ); - } catch (error) { - if ( - !payload.fallbackText || - classifyChatPublicationError(error, attempt).kind !== - "failed" - ) { - throw error; - } - } - if (!sent && payload.fallbackText) { - sent = await target.post(payload.fallbackText); - } - } - } else if (claim.endpoint.provider === "imessage-photon") { - const adapter = (await runtimeFor(claim.endpoint)).getProviderAdapter(); - if (!(adapter instanceof PhotonChatAdapter)) throw new Error("Photon runtime unavailable"); - const receipt = await adapter.publish(payload.threadId, `effect:${action.id}`, projectSafeChatPublicationText(payload.text), { - assertCurrent: async () => { await credentialLease.assertOwned(); }, - }); - sent = { id: receipt.id, threadId: payload.threadId }; - } else { - sent = await target.post(payload.text); - } - } - if (!sent) { - throw Object.assign( - new Error("Provider did not accept the message"), - { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, - ); - } - providerEffectCompleted = true; - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - // A duplicate webhook holds the endpoint while waiting for the - // delivery's unique-index entry. Lock this endpoint first too, - // before settling the action/conversation/delivery, so neither - // transaction can wait on a row already held by the other. - const currentEndpoint = await runtimeCallbackEndpoint( - tx, - claim.endpoint.id, - { - generation: payload.runtimeGeneration, - credentialFingerprint: payload.credentialFingerprint, - }, - ["verifying", "active"], - ); - await notifyChatActionWork(tx, "provider_effect"); - const [completed] = await tx - .update(chatActions) - .set({ - status: "processed", - result: { - attempts: attempt, - providerMessageId: sent.id, - threadId: sent.threadId, - ...(payload.effect === "telegram_callback_notice" - ? { - code: - payload.telegramCallback?.chatType === "private" - ? "telegram_private_api_accepted" - : "telegram_ephemeral_api_accepted", - } - : {}), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action!.id), - eq(chatActions.status, "processing"), - sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, - ), - ) - .returning({ id: chatActions.id }); - if (!completed) { - throw new Error( - "Provider effect ownership changed before commit", - ); - } - if (payload.completeConversationId) { - await tx - .update(chatConversations) - .set({ state: "completed", updatedAt: new Date() }) - .where( - and( - eq(chatConversations.id, payload.completeConversationId), - eq(chatConversations.endpointId, claim.endpoint.id), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ); - } - if (payload.settleDelivery && action!.deliveryId) { - await notifyChatDeliveryWork(tx); - const [settled] = await tx - .update(chatDeliveries) - .set({ - state: "processed", - processedAt: new Date(), - nextAttemptAt: null, - redactedError: null, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, action!.deliveryId), - eq(chatDeliveries.state, "processing"), - ), - ) - .returning({ id: chatDeliveries.id }); - if (!settled) { - throw new Error( - "Provider effect delivery ownership changed before commit", - ); - } - } - if (currentEndpoint) { - await tx - .update(chatEndpoints) - .set({ lastEventAt: new Date(), updatedAt: new Date() }) - .where(eq(chatEndpoints.id, currentEndpoint.id)); - } - await credentialLease.assertOwned(tx); - }); - return "processed"; - }, - ); - } catch (error) { - const disposition = providerEffectDisposition( - error, - attempt, - providerEffectCompleted, - ); - try { - await finalizeProviderEffectFailure({ - action, - endpoint: record?.endpoint ?? initialEndpoint.endpoint, - payload, - disposition, - attempt, - }); - } catch (finalizationError) { - if (disposition.kind === "delivery_unknown") { - throw Object.assign( - new Error( - "Provider effect is ambiguous and its durable quarantine failed", - { cause: finalizationError }, - ), - { code: "CHAT_PROVIDER_EFFECT_AMBIGUOUS" }, - ); - } - throw finalizationError; - } - throw error; - } - }; - return processClaimedEffect(); - } - function scheduleProviderEffect( actionId: string, liveTarget?: ProviderEffectTarget, @@ -3952,36 +2026,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); } - async function processPendingProviderEffects(limit = 25) { - const now = new Date(); - const staleBefore = new Date(now.getTime() - PROVIDER_EFFECT_STALE_MS); - const actions = await db - .select() - .from(chatActions) - .where(dueChatAction("provider_effect", now.getTime())) - .orderBy(asc(chatActions.createdAt)) - .limit(limit); - for (const action of actions) { - if (action.status === "processing" && action.updatedAt <= staleBefore) { - await quarantineStaleProviderEffect(action); - continue; - } - try { - await processProviderEffect(action.id); - } catch (error) { - logger.warn( - { - endpointId: action.endpointId, - actionId: action.id, - error: redactError(error), - }, - "chat provider effect reconciliation failed", - ); - } - } - return actions.length; - } - async function runtimeCallbackEndpoint( tx: DbTransaction, endpointId: string, @@ -4097,212 +2141,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - function discordGatewayFailureDetail( - event: DiscordGatewayCallbackEvent["event"], - ): string { - if (event.type === "guild_removed") { - return "Discord bot was removed from the configured server"; - } - if (event.type === "guild_unavailable") { - return "Discord server is temporarily unavailable; reconnecting"; - } - if (event.type === "disconnected") { - return `Discord Gateway disconnected${event.code === undefined ? "" : ` (code ${event.code})`}; reconnecting`; - } - if (event.type === "failure") { - const code = event.error.code ?? event.error.status; - return `Discord Gateway failed${code === undefined ? "" : ` (${String(code).slice(0, 64)})`}; ${event.fatal ? "credentials or privileged intents need attention" : "reconnecting"}`; - } - return "Discord Gateway connection needs attention"; - } - - function handleDiscordGatewayEvent( - callback: DiscordGatewayCallbackEvent, - context: RuntimeContext, - ): Promise { - const pending = (context.discordGatewayEventQueue ?? Promise.resolve()) - .catch(() => undefined) - .then(() => processDiscordGatewayEvent(callback, context)); - context.discordGatewayEventQueue = pending; - return pending; - } - - async function processDiscordGatewayEvent( - callback: DiscordGatewayCallbackEvent, - context: RuntimeContext, - ): Promise { - if ( - !(await ensureDiscordGatewayRuntimeIsCurrent( - callback.endpointId, - context, - )) - ) - return; - if (callback.sequence <= (context.discordGatewaySequence ?? 0)) return; - context.discordGatewaySequence = callback.sequence; - await options.discordGatewayEventBarrier?.(callback); - const record = await runtimeCallbackRecord(callback.endpointId, context, [ - "verifying", - "active", - "attention", - ]); - if (!record || record.endpoint.provider !== "discord") return; - const event = callback.event; - if (event.type === "channel_removed") { - await applyProviderLifecycleEffect( - record.endpoint, - { - kind: "resource", - provider: "discord", - providerEventId: `gateway:channel_removed:${event.channelId}`, - providerResourceId: event.channelId, - resourceType: "channel", - label: event.label?.trim() || event.channelId, - availability: "removed", - metadata: { source: "discord_gateway" }, - }, - context, - ); - return; - } - - await db.transaction(async (tx) => { - const current = await runtimeCallbackEndpoint( - tx, - callback.endpointId, - context, - ["verifying", "active", "attention"], - ); - if ( - !current || - current.provider !== "discord" || - !discordGatewayRuntimeIsCurrent(callback.endpointId, context) - ) { - return; - } - const now = new Date(); - if (event.type === "connecting") { - await tx - .update(chatEndpoints) - .set({ - healthMessage: "Discord Gateway connecting", - lastError: null, - lastEventAt: now, - updatedAt: now, - }) - .where(eq(chatEndpoints.id, current.id)); - await tx - .update(toolConnections) - .set({ - healthStatus: "unchecked", - healthMessage: "Discord Gateway connecting", - lastError: null, - healthCheckedAt: now, - updatedAt: now, - }) - .where(eq(toolConnections.id, current.connectionId)); - return; - } - if (event.type === "ready" || event.type === "guild_available") { - const gatewayOwnedAttention = - current.status === "attention" && - current.healthMessage?.startsWith("Discord ") === true; - if (current.status === "attention" && !gatewayOwnedAttention) return; - const status = - current.setup.step === "complete" ? "active" : "verifying"; - if (status !== current.status) await notifyChatEndpointWork(tx); - await tx - .update(chatEndpoints) - .set({ - status, - healthMessage: - status === "active" - ? "Connected" - : "Waiting for a test conversation", - lastError: null, - lastEventAt: now, - updatedAt: now, - }) - .where(eq(chatEndpoints.id, current.id)); - await tx - .update(toolConnections) - .set({ - status: "active", - enabled: true, - healthStatus: "healthy", - healthMessage: "Connected", - lastError: null, - healthCheckedAt: now, - updatedAt: now, - }) - .where(eq(toolConnections.id, current.connectionId)); - return; - } - - const detail = discordGatewayFailureDetail(event); - const transientGuildOutage = event.type === "guild_unavailable"; - const terminal = - event.type === "guild_removed" || - ((event.type === "failure" || event.type === "disconnected") && - event.fatal); - await tx - .update(chatEndpoints) - .set({ - status: transientGuildOutage - ? current.status - : event.type === "guild_removed" - ? "revoked" - : "attention", - healthMessage: detail, - lastError: detail, - lastEventAt: now, - ...(terminal - ? { - setup: { - ...current.setup, - runtimeGeneration: runtimeGeneration(current.setup) + 1, - } as InternalSetupState, - } - : {}), - updatedAt: now, - }) - .where(eq(chatEndpoints.id, current.id)); - await tx - .update(toolConnections) - .set({ - status: terminal ? "disabled" : "active", - enabled: !terminal, - healthStatus: event.type === "guild_removed" ? "failed" : "degraded", - healthMessage: detail, - lastError: detail, - healthCheckedAt: now, - updatedAt: now, - }) - .where(eq(toolConnections.id, current.connectionId)); - if (event.type === "guild_removed") { - await tx - .update(chatEndpointResources) - .set({ availability: "unavailable", updatedAt: now }) - .where( - and( - eq(chatEndpointResources.companyId, current.companyId), - eq(chatEndpointResources.endpointId, current.id), - ), - ); - await tx - .update(chatConversations) - .set({ state: "unavailable", updatedAt: now }) - .where( - and( - eq(chatConversations.companyId, current.companyId), - eq(chatConversations.endpointId, current.id), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ); - } - }); - } - function serializeEndpoint( row: Awaited>, ): ChatEndpoint { @@ -4414,7 +2252,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { actorUserId?: string | null, ) { if ((input.provider as string) === "agentmail") throw badRequest("Use the email inbox setup API for AgentMail"); - if (input.slackApp && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections"); + 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) @@ -4526,7 +2365,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { step: "provider_setup", ...(input.provider === "slack" ? { slackApp: input.slackApp ?? defaultSlackAppConfiguration(agent.name), - command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: "automatic" } + command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: input.slackSetupMethod ?? slackRegistration.managerGrants.defaultMethod() } : {}), }, }); @@ -4572,13 +2411,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } 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.status !== "failed") { - if (input.slackApp || input.slackSetupMethod === "automatic") throw conflict("Slack app details are locked after creation starts"); + 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); } @@ -5263,6 +3103,16 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } const withCredentialMutationLease = chatCredentialMutationLease(db, options); + const { setupTestStatus, test } = createSetupProbes(db, { endpointRecord, withCredentialMutationLease, fetchImpl, findAuthorizedIdentityLink, runtimeGeneration, lockCurrentPrincipalAuthorization, get, setupTestActivationBarrier: () => options.setupTestActivationBarrier?.() ?? Promise.resolve() }); + const { listConversations, listActivity, listActivityPage } = createChatActivity(db, { endpointRecord, teamsTransferProtocol }); + const { lockTelegramCallbackNoticeAuthority } = createTelegramCallbackNoticeAuthority({ nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization }); + const { stageProviderEffect, processProviderEffect, processPendingProviderEffects } = createProviderEffects(db, { endpointRecord, runtimeCallbackEndpoint, withCredentialMutationLease, runtimeFor, invalidateRuntime, lockCurrentPrincipalAuthorization, redactError, MAX_ERROR_TEXT, nonDirectDestinationAllowed, lockTelegramCallbackNoticeAuthority }); + const { publicationFiles } = createOutboundAttachments(db, { get storage() { return options.storage; } }); + const { ingestAttachments, attachmentOmissionDetail } = createInboundAttachments(db, { authorizeInboundWakeup, lifecycleRuntimeFence, sanitizeFilename, issuesSvc, isExternalActionAuthorizationChange, redactError, get storage() { return options.storage; } }); + const { handleReaction, processPendingReactionDeliveries } = createInboundReactions(db, { lockCurrentPrincipalAuthorization, runtimeCallbackRecord, runtimeCallbackEndpoint, ensurePrincipal, durableExternalThreadIdentity, recordCurrentMicrosoftTeamsRoute, lifecycleRuntimeFence, deliveryReady, redactError, nonDirectDestinationAllowed, reactionLinkPreflightBarrier: () => options.reactionLinkPreflightBarrier?.() ?? Promise.resolve(), reactionReplayEndpointLockBarrier: () => options.reactionReplayEndpointLockBarrier?.() ?? Promise.resolve(), reactionReplayConversationLockBarrier: () => options.reactionReplayConversationLockBarrier?.() ?? Promise.resolve() }); + const { addReceiptReaction, processReceiptReaction, processPendingReceiptReactions } = createReceiptReactions(db, { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, runtimeFor, fetchImpl, runtime, runtimeGeneration, credentialFingerprint, redactError, MAX_ERROR_TEXT, receiptReactionTransportBarrier: () => options.receiptReactionTransportBarrier?.() ?? Promise.resolve() }); + const { admitDiscordRootMention, discordRootThreadPending, reconcileDiscordRootThread } = createDiscordRootThreads(db, { ensureDiscordGatewayRuntimeIsCurrent, withCredentialMutationLease, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, processMessage, redactError, scheduleConversationDrain, lifecycleRuntimeFence, runtimeFor, DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS, discordRootThreadTransportBarrier: () => options.discordRootThreadTransportBarrier?.() ?? Promise.resolve() }); + const { handleDiscordGatewayEvent } = createDiscordGatewayEvents(db, { ensureDiscordGatewayRuntimeIsCurrent, discordGatewayRuntimeIsCurrent, runtimeCallbackRecord, runtimeCallbackEndpoint, applyProviderLifecycleEffect, runtimeGeneration, discordGatewayEventBarrier: (event) => options.discordGatewayEventBarrier?.(event) ?? Promise.resolve() }); const { handlePhotonFailure, handlePhotonEvent } = createPhotonEvents(db, { runtimeCallbackRecord, runtimeCallbackEndpoint, invalidateRuntime, ensurePrincipal, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications, questionResponses, redactError, renewDiscordGatewayOwnershipForMessageAdmission, handleSdkMessage }); const { handleTelegramGenerationStopped, reserveTelegramPublicationDraft } = createTelegramPublicationDrafts(db, { runtimeCallbackRecord, runtimeCallbackEndpoint, runtimeFor, runtime, runtimeContexts, credentialFingerprint, authorizeRetainedChatSourcePublication }); const { recordSlackFileUploadReceipt, stageSlackFilePublicationSuccess, processPendingSlackFileUploadReceipts, nextSlackReceiptAt } = createSlackFileReceipts(db, { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runtimeFor, isExplicitOperatorPublication, recordExactOutboundPublicationLink }); @@ -6886,7 +4736,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { await syncSpekoVoiceTools(tx, endpoint, actorUserId, true, options.allowLocalVoiceBoard === true); } const observedSlackUrl = (current.setup as InternalSetupState).slackCallbackSurfaces?.events?.url; - const preserveSlackVerification = current.setup.slackSetupMethod === "automatic" + 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); @@ -7136,288 +4986,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .limit(1).then((rows) => rows[0] ?? null); } - async function setupTestStatus(endpointId: string, userId: string) { - const record = await endpointRecord(endpointId); - if (!record) throw notFound("Chat endpoint not found"); - const startedAt = record.endpoint.setup.testStartedAt; - if (!startedAt) return { messageReceivedAt: null }; - const links = await db.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and( - eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId), - eq(chatIdentityLinks.paperclipUserId, userId), eq(chatIdentityLinks.status, "linked"), - )); - if (!links.length) return { messageReceivedAt: null }; - const principalIds = links.map((link) => link.principalId); - const [delivery, command] = await Promise.all([ - db.select({ at: chatDeliveries.createdAt }).from(chatDeliveries).where(and( - eq(chatDeliveries.companyId, record.endpoint.companyId), eq(chatDeliveries.endpointId, endpointId), - inArray(chatDeliveries.principalId, principalIds), gte(chatDeliveries.createdAt, new Date(startedAt)), - inArray(chatDeliveries.eventKind, ["message", "mention"]), - )).orderBy(asc(chatDeliveries.createdAt)).limit(1).then((rows) => rows[0]), - db.select({ at: chatActions.createdAt }).from(chatActions).where(and( - eq(chatActions.companyId, record.endpoint.companyId), eq(chatActions.endpointId, endpointId), - inArray(chatActions.principalId, principalIds), eq(chatActions.kind, "slash_task_start"), gte(chatActions.createdAt, new Date(startedAt)), - )).orderBy(asc(chatActions.createdAt)).limit(1).then((rows) => rows[0]), - ]); - const at = [delivery?.at, command?.at].filter((value): value is Date => Boolean(value)).sort((a, b) => a.getTime() - b.getTime())[0]; - return { messageReceivedAt: at?.toISOString() ?? null }; - } - - async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }) { - const initial = await endpointRecord(endpointId); - if (!initial) throw notFound("Chat endpoint not found"); - return withCredentialMutationLease( - initial.endpoint, - async (credentialLease) => { - // Reload only after owning the same lane as reconnect, pause, removal, - // and credential rotation. Qualification and activation must describe - // one exact provider runtime, never a setup snapshot that a concurrent - // reconnect has already superseded. - const record = await endpointRecord(endpointId); - if (!record) throw notFound("Chat endpoint not found"); - const endpoint = record.endpoint; - if (endpoint.status !== "verifying" || endpoint.setup.step !== "test") { - throw conflict("This connection is not waiting for a setup test", { - code: "chat_endpoint_not_testing", - }); - } - if (endpoint.provider === "github" && endpoint.setup.github) { - const verified = await githubChatManagementService(db, fetchImpl).verification(endpoint.id); - if (!verified.ready && !(finishOptions?.optionalSlackTestForUser && verified.connectionReady)) throw conflict("Finish verifying the App, repositories, and assigned agent's tools before activating this bot", { checks: verified.checks }); - } - const testStartedAtValue = endpoint.setup.testStartedAt; - const testStartedAt = testStartedAtValue - ? new Date(testStartedAtValue) - : null; - const optionalSlackTest = Boolean(finishOptions?.optionalSlackTestForUser); - if (optionalSlackTest) { - if (!["slack", "github"].includes(endpoint.provider) || !endpoint.setup.webhookVerifiedAt || !endpoint.providerAccountId) { - throw conflict("Verify the connection before finishing setup"); - } - const linked = await findAuthorizedIdentityLink(endpoint, finishOptions!.optionalSlackTestForUser); - if (!linked) throw forbidden("Connect your account before finishing setup"); - } - if (!optionalSlackTest) { - if ( - !endpoint.lastEventAt || - !testStartedAtValue || - !testStartedAt || - Number.isNaN(testStartedAt.getTime()) || - endpoint.lastEventAt < testStartedAt - ) { - throw conflict( - "Send the test message in the provider before completing setup", - { - code: "chat_test_message_missing", - }, - ); - } - const requiredTrigger = - ["telegram", "imessage-photon"].includes(endpoint.provider) - ? "direct_message" - : "subscribed_message"; - const qualifyingDelivery = await db - .select({ - id: chatDeliveries.id, - conversationId: chatDeliveries.conversationId, - processedAt: chatDeliveries.processedAt, - }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, endpoint.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - eq(chatDeliveries.state, "processed"), - gte(chatDeliveries.processedAt, testStartedAt), - sql`${chatDeliveries.normalizedEvent}->>'trigger' = ${requiredTrigger}`, - ), - ) - .orderBy(desc(chatDeliveries.processedAt)) - .limit(1) - .then((rows) => rows[0] ?? null); - if ( - !qualifyingDelivery?.conversationId || - !qualifyingDelivery.processedAt - ) { - throw conflict( - ["telegram", "imessage-photon"].includes(endpoint.provider) - ? "Send the test direct message before completing setup" - : "Reply once without mentioning the agent before completing setup", - { code: "chat_test_follow_up_missing" }, - ); - } - const finalPublication = await db - .select({ - commentId: chatPublications.commentId, - payload: chatPublications.payload, - }) - .from(chatPublications) - .innerJoin( - issueComments, - and( - eq(issueComments.id, chatPublications.commentId), - eq(issueComments.companyId, chatPublications.companyId), - eq(issueComments.issueId, chatPublications.issueId), - eq(issueComments.authorType, "agent"), - eq(issueComments.authorAgentId, endpoint.assignedAgentId), - ), - ) - .innerJoin( - chatMessageLinks, - and( - eq(chatMessageLinks.companyId, chatPublications.companyId), - eq(chatMessageLinks.endpointId, chatPublications.endpointId), - eq( - chatMessageLinks.conversationId, - chatPublications.conversationId, - ), - eq(chatMessageLinks.deliveryId, qualifyingDelivery.id), - eq(chatMessageLinks.direction, "inbound"), - isNotNull(chatMessageLinks.commentId), - ), - ) - .innerJoin( - heartbeatRuns, - and( - eq(heartbeatRuns.id, issueComments.createdByRunId), - eq(heartbeatRuns.companyId, chatPublications.companyId), - eq(heartbeatRuns.agentId, endpoint.assignedAgentId), - eq(heartbeatRuns.status, "succeeded"), - eq( - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, - sql`${chatPublications.issueId}::text`, - ), - or( - sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`, - sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'commentId'`, - sql`coalesce(${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, - ), - ), - ) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq( - chatPublications.conversationId, - qualifyingDelivery.conversationId, - ), - eq(chatPublications.state, "published"), - gte(chatPublications.publishedAt, qualifyingDelivery.processedAt), - ), - ) - .orderBy(desc(chatPublications.publishedAt)) - .then((rows) => - rows.find( - (row) => - row.payload.interactionId === undefined && - row.payload.progressState === undefined && - row.commentId !== null, - ), - ); - let githubToolReply = false; - if (!finalPublication && endpoint.provider === "github") { - const setupRuns = await db.select({ id: heartbeatRuns.id, issueId: chatConversations.issueId }) - .from(heartbeatRuns) - .innerJoin(chatMessageLinks, and( - eq(chatMessageLinks.companyId, endpoint.companyId), - eq(chatMessageLinks.endpointId, endpoint.id), - eq(chatMessageLinks.deliveryId, qualifyingDelivery.id), - eq(chatMessageLinks.direction, "inbound"), - or( - sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`, - sql`coalesce(${heartbeatRuns.contextSnapshot}->'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, - ), - )) - .innerJoin(chatConversations, and( - eq(chatConversations.id, qualifyingDelivery.conversationId), - eq(chatConversations.id, chatMessageLinks.conversationId), - eq(chatConversations.companyId, endpoint.companyId), - sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${chatConversations.issueId}::text`, - )) - .where(and(eq(heartbeatRuns.companyId, endpoint.companyId), - eq(heartbeatRuns.agentId, endpoint.assignedAgentId), eq(heartbeatRuns.status, "succeeded"))) - .orderBy(desc(heartbeatRuns.createdAt)).limit(20); - for (const run of setupRuns) { - if (await githubRunReplyState(db, { companyId: endpoint.companyId, endpointId: endpoint.id, - issueId: run.issueId, runId: run.id }) === "confirmed") { - githubToolReply = true; - break; - } - } - } - if (!finalPublication && !githubToolReply) { - throw conflict( - "Wait for the Paperclip agent to reply to the setup turn before completing setup", - { - code: "chat_test_round_trip_incomplete", - }, - ); - } - } - await options.setupTestActivationBarrier?.(); - const expectedGeneration = runtimeGeneration(endpoint.setup); - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - if (optionalSlackTest) { - const linked = await tx.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and( - eq(chatIdentityLinks.endpointId, endpoint.id), eq(chatIdentityLinks.paperclipUserId, finishOptions!.optionalSlackTestForUser), eq(chatIdentityLinks.status, "linked"), - )).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); - const [activated] = await tx - .update(chatEndpoints) - .set({ - status: "active", - setup: { - ...endpoint.setup, - step: "complete", - testStartedAt: null, - testSkipped: optionalSlackTest, - }, - healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected", - activatedAt: endpoint.activatedAt ?? new Date(), - updatedAt: new Date(), - }) - .where( - and( - eq(chatEndpoints.id, endpointId), - eq(chatEndpoints.status, "verifying"), - sql`${chatEndpoints.setup}->>'step' = 'test'`, - sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${expectedGeneration}`, - sql`${chatEndpoints.setup}->>'testStartedAt' = ${testStartedAtValue}`, - ), - ) - .returning({ id: chatEndpoints.id }); - if (!activated) { - throw conflict("This setup test was superseded by a reconnect", { - code: "chat_endpoint_test_superseded", - }); - } - await tx - .update(toolConnections) - .set({ - status: "active", - enabled: true, - healthStatus: "healthy", - healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected", - lastError: null, - healthCheckedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - if (optionalSlackTest) await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, actorType: "user", actorId: finishOptions!.optionalSlackTestForUser, - action: "chat.setup_completed", entityType: "chat_endpoint", entityId: endpointId, - details: { conversationTestOptional: true }, - }); - await credentialLease.assertOwned(tx); - }); - return get(endpointId); - }, - ); - } - async function ensurePrincipal( endpoint: EndpointRow, author: Author, @@ -7788,595 +5356,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return resource; } - async function ingestAttachments(input: { - endpoint: EndpointRow; - endpointRuntime: ChatEndpointRuntime; - deliveryId: string; - issueId: string; - issueCommentId: string; - attachments: Attachment[]; - attachmentLimitOmissions?: number; - unavailableReferenceCount?: number; - actorUserId: string | null; - }): Promise<{ - storedIds: string[]; - omissionReasons: Record; - }> { - const omissionReasons: Record = {}; - const omit = (reason: string, count = 1) => { - omissionReasons[reason] = (omissionReasons[reason] ?? 0) + count; - }; - const boundedAttachments = input.attachments.slice(0, 20); - const photonDerivatives = new Map(); - const originalPhotonAttachments = new Map(); - if ( - input.endpoint.provider === "microsoft-teams" && - input.unavailableReferenceCount - ) { - // A channel/group reference is not a download capability. Still tell - // the exact current-turn consumer that those bytes are unavailable, - // including when there are no ingestible attachments at all. - omit("download_unavailable", input.unavailableReferenceCount); - } - if ( - ["github", "imessage-photon"].includes(input.endpoint.provider) && - input.attachmentLimitOmissions - ) { - omit("attachment_limit", input.attachmentLimitOmissions); - } - if (input.attachments.length > boundedAttachments.length) { - omit( - "attachment_limit", - input.attachments.length - boundedAttachments.length, - ); - } - if (!options.storage) { - if (boundedAttachments.length) - omit("storage_unavailable", boundedAttachments.length); - return { storedIds: [], omissionReasons }; - } - const existingByFingerprint = new Map(); - const existingAttachments = await db - .select({ - id: issueAttachments.id, - byteSize: assets.byteSize, - contentType: assets.contentType, - originalFilename: assets.originalFilename, - sha256: assets.sha256, - }) - .from(issueAttachments) - .innerJoin( - assets, - and( - eq(assets.companyId, issueAttachments.companyId), - eq(assets.id, issueAttachments.assetId), - ), - ) - .where( - and( - eq(issueAttachments.companyId, input.endpoint.companyId), - eq(issueAttachments.issueId, input.issueId), - eq(issueAttachments.issueCommentId, input.issueCommentId), - ), - ); - for (const existing of existingAttachments) { - const fingerprint = JSON.stringify([ - existing.sha256, - existing.byteSize, - existing.contentType, - existing.originalFilename, - ]); - const ids = existingByFingerprint.get(fingerprint) ?? []; - ids.push(existing.id); - existingByFingerprint.set(fingerprint, ids); - } - const storedIds: string[] = []; - const withCurrentAttachmentAuthorization = async ( - work: (tx: DbTransaction) => Promise, - enforceUnchangedSource = false, - telegramMedia?: Attachment, - ): Promise => { - for (let attempt = 0; ; attempt++) { - try { - return await db.transaction(async (tx) => { - const action = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, input.endpoint.companyId), - eq(chatActions.endpointId, input.endpoint.id), - eq(chatActions.deliveryId, input.deliveryId), - eq(chatActions.kind, "inbound_wakeup"), - ), - ) - .limit(1) - .then((rows) => rows[0]); - if ( - !action || - !["preparing", "issued", "processing", "processed"].includes( - action.status, - ) || - action.payload.issueId !== input.issueId || - action.payload.commentId !== input.issueCommentId - ) - throw forbidden( - "The attachment's current chat admission is unavailable", - { code: "chat_action_authorization_changed" }, - ); - const current = await authorizeInboundWakeup(tx, action); - // Delivery settlement/replay can update these receipts without - // taking the issue lock. Lock in delivery -> action order only - // after policy authorization; NOWAIT breaks cross-worker lock - // inversions and the outer loop retries the whole transaction. - const lockedDelivery = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, input.endpoint.companyId), - eq(chatDeliveries.id, input.deliveryId), - ), - ) - .for("update", { noWait: true }) - .limit(1) - .then((rows) => rows[0]); - const lockedAction = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, input.endpoint.companyId), - eq(chatActions.id, action.id), - ), - ) - .for("update", { noWait: true }) - .limit(1) - .then((rows) => rows[0]); - const scopeKeys = [ - "version", - "issueId", - "commentId", - "agentId", - "sessionGeneration", - "requestedByActorType", - "requestedByActorId", - ] as const; - if ( - current.delivery.state !== "processing" || - !lockedDelivery || - !lockedAction || - lockedDelivery.state !== "processing" || - lockedDelivery.endpointId !== action.endpointId || - lockedDelivery.conversationId !== action.conversationId || - lockedDelivery.principalId !== action.principalId || - lockedDelivery.attempts !== current.delivery.attempts || - JSON.stringify(lifecycleRuntimeFence(lockedDelivery)) !== - JSON.stringify(lifecycleRuntimeFence(current.delivery)) || - lockedAction.endpointId !== action.endpointId || - lockedAction.deliveryId !== action.deliveryId || - lockedAction.conversationId !== action.conversationId || - lockedAction.principalId !== action.principalId || - lockedAction.kind !== "inbound_wakeup" || - !["preparing", "issued", "processing", "processed"].includes( - lockedAction.status, - ) || - scopeKeys.some( - (key) => lockedAction.payload[key] !== action.payload[key], - ) - ) - throw forbidden( - "The attachment's current chat admission is unavailable", - { code: "chat_action_authorization_changed" }, - ); - if (enforceUnchangedSource) { - if (telegramMedia) { - const normalized = lockedDelivery.normalizedEvent as { - principal?: { externalId?: string }; - message?: { - providerMessageId?: string; - attachments?: Array<{ recovery?: unknown }>; - }; - }; - const fence = lifecycleRuntimeFence(lockedDelivery); - const descriptor = - input.endpointRuntime.attachmentRecoveryDescriptor( - telegramMedia, - { - threadId: current.conversation.externalThreadId, - messageId: normalized.message?.providerMessageId ?? "", - principalExternalId: normalized.principal?.externalId, - runtimeGeneration: fence?.generation, - credentialFingerprint: fence?.credentialFingerprint, - }, - ); - if ( - descriptor?.locator.kind !== "telegram_media" || - !normalized.message?.attachments?.some((item) => { - const retained = item.recovery as { - version?: unknown; - provider?: unknown; - locator?: Record; - attachment?: Record; - } | null; - return ( - retained?.version === 1 && - retained.provider === "telegram" && - Object.entries(descriptor.locator).every( - ([key, value]) => retained.locator?.[key] === value, - ) && - Object.entries(descriptor.attachment).every( - ([key, value]) => retained.attachment?.[key] === value, - ) - ); - }) - ) - throw forbidden( - "The media's admitted source is unavailable", - { code: "chat_action_authorization_changed" }, - ); - } - const [source] = await tx - .select({ id: issueComments.id }) - .from(issueComments) - .where( - and( - eq(issueComments.companyId, input.endpoint.companyId), - eq(issueComments.issueId, input.issueId), - eq(issueComments.id, input.issueCommentId), - isNull(issueComments.deletedAt), - sql`${issueComments.updatedAt} = ${issueComments.createdAt}`, - ), - ) - .for("share", { noWait: true }); - // Lifecycle admission precedes its conversation drain. Pending - // source changes already invalidate this image download, even - // when the append-only original comment is still untouched. - const [lifecycle] = await tx - .select({ id: chatDeliveries.id }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, input.endpoint.companyId), - eq(chatDeliveries.endpointId, input.endpoint.id), - or( - isNull(chatDeliveries.conversationId), - eq( - chatDeliveries.conversationId, - current.conversation.id, - ), - ), - ne(chatDeliveries.state, "filtered"), - inArray(chatDeliveries.eventKind, [ - "message_updated", - "message_deleted", - "message_restored", - ]), - sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(lifecycleRuntimeFence(lockedDelivery))}::jsonb`, - sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${lockedDelivery.providerEventId}`, - ), - ) - .limit(1); - if (!source || lifecycle) - throw forbidden( - "The image's admitted source changed before attachment registration", - { - code: "chat_action_authorization_changed", - }, - ); - } - return await work(tx); - }); - } catch (error) { - if (!isExternalChatWaitAuthorizationContention(error) || attempt >= 4) - throw error; - // Retry the complete short transaction; no network/storage work or - // backoff occurs while governance rows are locked. - await new Promise((resolve) => - setTimeout(resolve, 20 * (attempt + 1)), - ); - } - } - }; - // One shared deadline bounds the sequential batch; an expired batch never - // starts another GitHub request. Other providers retain their own policy. - const githubBatchSignal = - input.endpoint.provider === "github" - ? AbortSignal.timeout(GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS) - : undefined; - // This new image-only lane shares one budget across token acquisition and - // downloads. DB/storage commit semantics remain explicit and unchanged. - const teamsInlineBatchSignal = - input.endpoint.provider === "microsoft-teams" - ? AbortSignal.timeout(10_000) - : undefined; - for (let attachment of boundedAttachments) { - const teamsInlineImage = - input.endpoint.provider === "microsoft-teams" && - input.endpointRuntime.attachmentRecoveryDescriptor(attachment)?.locator - .kind === "teams_inline_image"; - const telegramMedia = - input.endpoint.provider === "telegram" && - hasTelegramMediaProvenance(attachment); - const sourceBoundMedia = - teamsInlineImage || - telegramMedia || - input.endpoint.provider === "imessage-photon"; - const requireCurrentAttachmentAuthorization = - input.endpoint.provider === "github" || sourceBoundMedia; - try { - if (teamsInlineImage && teamsInlineBatchSignal?.aborted) { - omit("download_unavailable"); - continue; - } - // GitHub's anonymized upload URLs carry no trustworthy MIME metadata. - // Resolve public bytes only here, after durable comment admission, and - // then apply the same storage/type policy as every native attachment. - if (input.endpoint.provider === "github") { - await withCurrentAttachmentAuthorization(async () => {}); - attachment = await prepareGitHubPublicAttachment( - attachment, - githubBatchSignal, - async (request, signal) => { - await withCurrentAttachmentAuthorization(async () => {}); - return ( - (await input.endpointRuntime.resolveGitHubAttachmentComment?.( - request, - signal, - )) ?? null - ); - }, - ); - } - if ( - requireCurrentAttachmentAuthorization && - input.endpoint.provider !== "github" - ) - await withCurrentAttachmentAuthorization( - async () => {}, - sourceBoundMedia, - telegramMedia ? attachment : undefined, - ); - if ( - attachment.size !== undefined && - attachment.size > MAX_ATTACHMENT_BYTES - ) { - omit("declared_too_large"); - continue; - } - if (!attachment.fetchData) { - omit("download_unavailable"); - continue; - } - const originalFilename = sanitizeFilename(attachment.name); - let contentType = normalizeUploadAttachmentContentType({ - contentType: normalizeContentType( - attachment.mimeType ?? "application/octet-stream", - ), - originalFilename, - isAllowedContentType, - }); - // Reject a provider-declared type before allocating the downloaded - // payload. The actual byte length is checked again after the adapter's - // bounded fetch contract resolves. - // A filename may infer an Office MIME for ordinary documents, but it - // must not bypass byte identification for native media without MIME. - const identifyTelegram = - telegramMedia && - telegramMediaNeedsIdentification(attachment) && - normalizeContentType( - attachment.mimeType ?? "application/octet-stream", - ) === "application/octet-stream"; - if (!isAllowedContentType(contentType) && !identifyTelegram) { - omit("unsupported_type"); - continue; - } - const fetched = teamsInlineImage - ? await input.endpointRuntime.fetchTeamsInlineImage( - attachment, - teamsInlineBatchSignal!, - ) - : await attachment.fetchData(); - const body = Buffer.isBuffer(fetched) ? fetched : Buffer.from(fetched); - if (body.length === 0) { - omit("empty_download"); - continue; - } - if (body.length > MAX_ATTACHMENT_BYTES) { - omit("downloaded_too_large"); - continue; - } - if (identifyTelegram) { - contentType = - identifyTelegramMedia(attachment, body) ?? - "application/octet-stream"; - if (!isAllowedContentType(contentType)) { - omit("unsupported_type"); - continue; - } - } - const photonSourceHash = createHash("sha256") - .update(body) - .digest("hex"); - if (input.endpoint.provider === "imessage-photon") { - await validatePhotonImage(body, contentType); - const companion = takePhotonCompanion(body); - if (companion?.unavailable) omit("companion_unavailable"); - else if (companion) { - const video: Attachment = { type: "video", name: companion.fileName, mimeType: companion.mimeType, size: companion.data.length, fetchData: async () => companion.data }; - photonDerivatives.set(video, { source: attachment, sourceHash: photonSourceHash, kind: "live_photo_video" }); - boundedAttachments.push(video); - } - if ( - HEIF_CONTENT_TYPES.has(contentType) && - isAllowedContentType("image/jpeg") - ) { - try { - const preview = await photonHeifPreview(body); - const derivative: Attachment = { - type: "image", - name: `${originalFilename} (JPEG preview).jpg`, - mimeType: "image/jpeg", - size: preview.length, - fetchData: async () => preview, - }; - photonDerivatives.set(derivative, { source: attachment, sourceHash: photonSourceHash, kind: "heif_jpeg_preview" }); - boundedAttachments.push(derivative); - } catch { - omit("preview_unavailable"); - } - } - } - const fingerprint = JSON.stringify([ - photonSourceHash, - body.length, - contentType, - originalFilename, - ]); - const existingIds = existingByFingerprint.get(fingerprint); - const existingId = existingIds?.shift(); - if (requireCurrentAttachmentAuthorization) { - // Revoke during either API resolution or the anonymous byte download - // must prevent storage and attachment registration for that input. - await withCurrentAttachmentAuthorization( - async () => {}, - sourceBoundMedia, - telegramMedia ? attachment : undefined, - ); - } - const registerPhotonProvenance = async (attachmentId: string) => { - if (input.endpoint.provider !== "imessage-photon") return; - const relation = photonDerivatives.get(attachment); - if (!relation) { - originalPhotonAttachments.set(attachment, attachmentId); - return; - } - const originalAttachmentId = - originalPhotonAttachments.get(relation.source); - if (!originalAttachmentId) - throw new Error("Photon related attachment source is missing"); - await db - .insert(chatActions) - .values({ - companyId: input.endpoint.companyId, - endpointId: input.endpoint.id, - deliveryId: input.deliveryId, - kind: relation.kind === "heif_jpeg_preview" ? "attachment_derivative" : "attachment_companion", - providerActionId: `photon-${relation.kind}:${input.deliveryId}:${originalAttachmentId}`, - payload: { - originalAttachmentId, - derivativeAttachmentId: attachmentId, - originalSha256: relation.sourceHash, - derivativeSha256: photonSourceHash, - kind: relation.kind, - }, - status: "processed", - }) - .onConflictDoNothing(); - }; - if (existingId) { - storedIds.push(existingId); - await registerPhotonProvenance(existingId); - continue; - } - const stored = await options.storage.putFile({ - companyId: input.endpoint.companyId, - namespace: `issues/${input.issueId}`, - originalFilename, - contentType, - body, - }); - const registration = { - issueId: input.issueId, - issueCommentId: input.issueCommentId, - provider: stored.provider, - objectKey: stored.objectKey, - contentType: stored.contentType, - byteSize: stored.byteSize, - sha256: stored.sha256, - originalFilename: stored.originalFilename, - createdByUserId: input.actorUserId, - }; - let row; - try { - row = requireCurrentAttachmentAuthorization - ? await withCurrentAttachmentAuthorization( - (tx) => - issueService(tx as unknown as Db).createAttachment( - registration, - ), - sourceBoundMedia, - telegramMedia ? attachment : undefined, - ) - : await issuesSvc.createAttachment(registration); - } catch (error) { - // An explicit authorization denial happens before registration. Do - // not delete on ambiguous database errors which may have committed. - if ( - requireCurrentAttachmentAuthorization && - isExternalActionAuthorizationChange(error) - ) - await options.storage - .deleteObject(input.endpoint.companyId, stored.objectKey) - .catch(() => undefined); - throw error; - } - storedIds.push(row.id); - await registerPhotonProvenance(row.id); - } catch (error) { - if (isExternalActionAuthorizationChange(error)) throw error; - if ( - error instanceof PhotonError && - ["attachment_not_ready", "network", "quota", "credentials"].includes( - error.code, - ) - ) - throw error; - // Use the closed current-input omission vocabulary consumed by native - // prompts; provider-specific diagnostics remain redacted log codes. - omit( - error instanceof GitHubAttachmentUnavailableError - ? "download_unavailable" - : "processing_failed", - ); - // A malformed or unavailable provider attachment must not strand the - // durable text delivery. The rejected file is intentionally omitted; - // the delivery remains auditable through its normalized attachment - // metadata without persisting provider credentials or download URLs. - logger.warn( - { - endpointId: input.endpoint.id, - issueId: input.issueId, - ...(error instanceof GitHubAttachmentUnavailableError - ? { - deliveryId: input.deliveryId, - attachmentDiagnosticCode: error.code, - } - : {}), - attachmentName: sanitizeFilename(attachment.name), - error: redactError(error), - }, - "external chat attachment was rejected", - ); - } - } - return { storedIds, omissionReasons }; - } - - function attachmentOmissionDetail(result: { - omissionReasons: Record; - }): string | null { - const entries = Object.entries(result.omissionReasons).filter( - ([, count]) => count > 0, - ); - const omitted = entries.reduce((total, [, count]) => total + count, 0); - if (!omitted) return null; - const reasons = entries - .map(([reason, count]) => `${reason.replaceAll("_", " ")}: ${count}`) - .join(", "); - return `${omitted} external attachment${omitted === 1 ? " was" : "s were"} omitted (${reasons})`; - } - async function settleTelegramAttachmentOnlyFailure(input: { activeDelivery: DeliveryRow; attachmentResult: { @@ -8484,317 +5463,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return true; } - async function admitDiscordRootMention( - configuredEndpoint: EndpointRow, - event: DiscordRootMentionAdmissionEvent, - context: RuntimeContext, - ): Promise { - if ( - !(await ensureDiscordGatewayRuntimeIsCurrent( - configuredEndpoint.id, - context, - )) - ) { - return false; - } - return withCredentialMutationLease( - configuredEndpoint, - async (credentialLease) => { - const threadId = `discord:${event.guildId}:${event.channelId}:${event.messageId}`; - const providerEventId = `${threadId}:${event.messageId}`; - const allowed = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const endpoint = await runtimeCallbackEndpoint( - tx, - configuredEndpoint.id, - context, - ["verifying", "active"], - ); - const existing = await tx - .select({ state: chatDeliveries.state }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, configuredEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - ), - ) - .then((rows) => rows[0] ?? null); - if ( - existing && - ["processed", "filtered", "failed"].includes(existing.state) - ) - return false; - - let reason: string | null = null; - if (!endpoint || endpoint.providerAccountId !== event.guildId) { - reason = "Connection is not active"; - } - const resource = endpoint - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.type, "channel"), - eq( - chatEndpointResources.providerResourceId, - event.channelId, - ), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const enabledCount = - endpoint?.status === "verifying" - ? await tx - .select({ count: sql`count(*)::int` }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.type, "channel"), - eq(chatEndpointResources.enabled, true), - eq(chatEndpointResources.availability, "available"), - ), - ) - .then((rows) => rows[0]?.count ?? 0) - : 0; - const setupDestination = - endpoint?.status === "verifying" && - enabledCount === 0 && - resource?.availability === "available"; - if ( - !reason && - !( - endpoint?.allowGroupChats && - resource?.availability === "available" && - (resource.enabled || setupDestination) - ) - ) { - reason = "Destination is not enabled in Paperclip"; - } - - if (!reason && endpoint) { - const principal = await tx - .select({ id: chatExternalPrincipals.id }) - .from(chatExternalPrincipals) - .where( - and( - eq(chatExternalPrincipals.companyId, endpoint.companyId), - eq(chatExternalPrincipals.provider, "discord"), - eq(chatExternalPrincipals.providerAccountId, event.guildId), - eq(chatExternalPrincipals.externalId, event.userId), - ), - ) - .then((rows) => rows[0] ?? null); - let allowed: boolean; - let linkedDenied = false; - if (principal) { - const authorization = await lockCurrentPrincipalAuthorization( - tx, - endpoint, - principal.id, - ); - allowed = authorization.allowed; - linkedDenied = authorization.linkedDenied; - } else if (!endpoint.allowUnlinkedPeople) { - allowed = false; - } else if (!endpoint.sponsorUserId) { - allowed = true; - } else { - const sponsor = 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); - allowed = - sponsor?.status === "active" && - sponsor.membershipRole !== "viewer"; - } - if (!allowed) { - reason = linkedDenied - ? "Linked Paperclip account is not currently permitted" - : endpoint.allowUnlinkedPeople - ? "Endpoint sponsor can no longer authorize external guests" - : "External identity must be linked to a Paperclip account"; - } - } - if (!reason) { - await credentialLease.assertOwned(tx); - return true; - } - - const filteredAt = new Date(); - const normalizedEvent = { - providerEventId, - kind: "mention", - trigger: "mention", - resource: { - type: "channel", - providerResourceId: event.channelId, - }, - conversation: { externalThreadId: threadId }, - message: { providerMessageId: event.messageId }, - filtering: { contentRetained: false, providerThreadCreated: false }, - }; - await notifyChatDeliveryWork(tx); - await tx - .insert(chatDeliveries) - .values({ - companyId: configuredEndpoint.companyId, - endpointId: configuredEndpoint.id, - providerEventId, - deduplicationKey: createHash("sha256") - .update(providerEventId) - .digest("hex"), - eventKind: "mention", - normalizedEvent, - state: "filtered", - redactedError: reason, - processedAt: filteredAt, - }) - .onConflictDoUpdate({ - target: [ - chatDeliveries.endpointId, - chatDeliveries.providerEventId, - ], - set: { - normalizedEvent, - principalId: null, - state: "filtered", - nextAttemptAt: null, - redactedError: reason, - processedAt: filteredAt, - updatedAt: filteredAt, - }, - }); - await tx - .update(chatEndpoints) - .set({ lastEventAt: filteredAt, updatedAt: filteredAt }) - .where(eq(chatEndpoints.id, configuredEndpoint.id)); - await credentialLease.assertOwned(tx); - return false; - }); - if (!allowed || !event.message || !context.endpointRuntime) { - return false; - } - const thread = context.endpointRuntime.thread(event.threadId); - await processMessage( - configuredEndpoint, - thread, - event.message, - "mention", - true, - null, - context, - undefined, - null, - true, - undefined, - true, - ); - const staged = await db - .select({ state: chatDeliveries.state }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, configuredEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - ), - ) - .then((rows) => rows[0] ?? null); - if ( - !staged || - !["received", "retry", "processing"].includes(staged.state) - ) { - return false; - } - - await options.discordRootThreadTransportBarrier?.(); - await credentialLease.assertOwned(); - if ( - !(await ensureDiscordGatewayRuntimeIsCurrent( - configuredEndpoint.id, - context, - )) - ) { - return false; - } - try { - await context.endpointRuntime.ensureDiscordRootThread({ - channelId: event.channelId, - content: event.message.text, - messageId: event.messageId, - }); - } catch (error) { - // The durable provisional receipt owns recovery. Do not let a - // transient REST failure escape the Gateway event handler or ask the - // adapter to create the same thread outside Paperclip's lifecycle - // fence. - logger.warn( - { - endpointId: configuredEndpoint.id, - error: redactError(error), - providerEventId, - }, - "Discord root thread creation will be retried from the durable receipt", - ); - return false; - } - const promoted = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await runtimeCallbackEndpoint( - tx, - configuredEndpoint.id, - context, - ["verifying", "active"], - ); - if (!current) return false; - await notifyChatDeliveryWork(tx); - const [row] = await tx - .update(chatDeliveries) - .set({ - state: "received", - normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`, - nextAttemptAt: null, - redactedError: null, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.endpointId, configuredEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - eq(chatDeliveries.state, "retry"), - sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, - ), - ) - .returning({ id: chatDeliveries.id }); - await credentialLease.assertOwned(tx); - return Boolean(row); - }); - if (promoted) - scheduleConversationDrain(configuredEndpoint.id, threadId); - // Paperclip created the provider thread inside its credential/reach - // lease. Returning false prevents the adapter from creating it again; - // the durable drain owns the remaining task mutation. - return false; - }, - ); - } - async function stageInboundWakeup( tx: DbOrTransaction, input: { @@ -12154,197 +8822,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - function discordRootThreadPending( - delivery: Pick, - ): boolean { - return ( - Boolean(delivery.normalizedEvent) && - typeof delivery.normalizedEvent === "object" && - !Array.isArray(delivery.normalizedEvent) && - (delivery.normalizedEvent as { providerThreadPending?: unknown }) - .providerThreadPending === true - ); - } - - async function reconcileDiscordRootThread( - endpoint: EndpointRow, - delivery: DeliveryRow, - threadId: string, - ): Promise { - try { - const normalized = delivery.normalizedEvent as { - message?: { providerMessageId?: unknown; text?: unknown }; - resource?: { providerResourceId?: unknown }; - }; - const discordThreadParts = threadId.split(":"); - const channelId = - discordThreadParts[0] === "discord" && discordThreadParts.length >= 4 - ? discordThreadParts[2] - : normalized.resource?.providerResourceId; - const messageId = normalized.message?.providerMessageId; - if (typeof channelId !== "string" || typeof messageId !== "string") { - throw new DiscordAdapterCompatibilityError( - "the provisional root receipt is missing its channel or message id", - ); - } - const runtimeFence = lifecycleRuntimeFence(delivery); - if (!runtimeFence) { - throw new DiscordAdapterCompatibilityError( - "the provisional root receipt is missing its runtime fence", - ); - } - return await withCredentialMutationLease( - endpoint, - async (credentialLease) => { - const currentEndpoint = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await runtimeCallbackEndpoint( - tx, - endpoint.id, - runtimeFence, - ["verifying", "active"], - ); - await credentialLease.assertOwned(tx); - return current; - }); - if (!currentEndpoint) { - throw conflict( - "Discord root thread credentials or connection state changed before recovery", - { code: "chat_endpoint_runtime_superseded" }, - ); - } - const endpointRuntime = await runtimeFor(currentEndpoint); - await credentialLease.assertOwned(); - await endpointRuntime.ensureDiscordRootThread({ - channelId, - messageId, - content: - typeof normalized.message?.text === "string" - ? normalized.message.text - : "Paperclip task", - }); - return await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await runtimeCallbackEndpoint( - tx, - endpoint.id, - runtimeFence, - ["verifying", "active"], - ); - if (!current) return false; - await notifyChatDeliveryWork(tx); - const [promoted] = await tx - .update(chatDeliveries) - .set({ - state: "received", - normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`, - nextAttemptAt: null, - redactedError: null, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, delivery.id), - eq(chatDeliveries.state, "retry"), - sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, - ), - ) - .returning({ id: chatDeliveries.id }); - await credentialLease.assertOwned(tx); - return Boolean(promoted); - }); - }, - ); - } catch (error) { - const disposition = classifyChatPublicationError( - error, - delivery.attempts + 1, - ); - const terminal = - disposition.kind === "resource_unavailable" || - disposition.kind === "failed"; - const retryAfterMs = - disposition.kind === "retry" - ? disposition.retryAfterMs - : DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS; - const now = new Date(); - await db.transaction(async (tx) => { - if (disposition.kind === "resource_unavailable") { - const fence = lifecycleRuntimeFence(delivery); - const normalized = delivery.normalizedEvent as { - resource?: { providerResourceId?: unknown }; - }; - const providerResourceId = normalized.resource?.providerResourceId; - const current = fence - ? await runtimeCallbackEndpoint(tx, endpoint.id, fence, [ - "verifying", - "active", - "attention", - ]) - : null; - const resource = - current && typeof providerResourceId === "string" - ? await tx - .select({ id: chatEndpointResources.id }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, current.companyId), - eq(chatEndpointResources.endpointId, current.id), - eq( - chatEndpointResources.providerResourceId, - providerResourceId, - ), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - if (resource) { - await tx - .update(chatEndpointResources) - .set({ availability: "unavailable", updatedAt: now }) - .where(eq(chatEndpointResources.id, resource.id)); - await tx - .update(chatConversations) - .set({ state: "unavailable", updatedAt: now }) - .where( - and( - eq(chatConversations.companyId, current!.companyId), - eq(chatConversations.endpointId, current!.id), - eq(chatConversations.resourceId, resource.id), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ); - } - } - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: terminal ? "filtered" : "retry", - attempts: delivery.attempts + 1, - nextAttemptAt: terminal - ? null - : new Date(now.getTime() + retryAfterMs), - processedAt: terminal ? now : null, - redactedError: terminal - ? "Discord thread creation was not confirmed" - : redactError(error), - updatedAt: now, - }) - .where( - and( - eq(chatDeliveries.id, delivery.id), - eq(chatDeliveries.state, "retry"), - sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, - ), - ); - }); - return false; - } - } - function normalizedDeliveryThreadId(delivery: Pick): string | null { const normalized = delivery.normalizedEvent as { conversation?: { externalThreadId?: unknown }; @@ -14156,479 +10633,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - function reactionConversationThreadCondition( - provider: ChatProvider, - threadId: string, - ): SQL | undefined { - const exact = externalThreadIdentityCondition( - sql`${chatConversations.externalThreadId}`, - threadId, - ); - const slackDmChannel = - provider === "slack" ? slackThreadChannelId(threadId) : null; - return slackDmChannel && /^D[A-Z0-9-]*$/i.test(slackDmChannel) - ? or( - exact, - and( - eq(chatConversations.isDirectMessage, true), - eq(chatConversations.externalThreadId, `slack:${slackDmChannel}:`), - ), - ) - : exact; - } - - async function reactionConversationForMessage( - database: DbOrTransaction, - endpoint: EndpointRow, - threadId: string, - providerMessageId: string, - direction?: "outbound", - ): Promise { - return database - .select({ conversation: chatConversations }) - .from(chatMessageLinks) - .innerJoin( - chatConversations, - and( - eq(chatConversations.companyId, chatMessageLinks.companyId), - eq(chatConversations.endpointId, chatMessageLinks.endpointId), - eq(chatConversations.id, chatMessageLinks.conversationId), - ), - ) - .where( - and( - eq(chatMessageLinks.companyId, endpoint.companyId), - eq(chatMessageLinks.endpointId, endpoint.id), - eq(chatMessageLinks.providerMessageId, providerMessageId), - direction ? eq(chatMessageLinks.direction, direction) : undefined, - reactionConversationThreadCondition(endpoint.provider, threadId), - ), - ) - .orderBy(desc(chatConversations.sessionGeneration)) - .limit(1) - .then((rows) => rows[0]?.conversation ?? null); - } - - async function lockReactionConversationForMessage( - tx: DbTransaction, - endpoint: EndpointRow, - threadId: string, - providerMessageId: string, - direction?: "outbound", - ): Promise { - const candidate = await reactionConversationForMessage( - tx, - endpoint, - threadId, - providerMessageId, - direction, - ); - if (!candidate) return null; - const current = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, candidate.id), - ), - ) - // Publication settlement inserts the outbound message link before it - // updates the endpoint. NO KEY UPDATE serializes state changes while - // remaining compatible with that link's conversation FK KEY SHARE. - .for("no key update") - .then((rows) => rows[0] ?? null); - if ( - !current || - !(current.isDirectMessage - ? ["active", "waiting", "completed"].includes(current.state) - : ["active", "waiting"].includes(current.state)) - ) { - return null; - } - const linkStillExists = await tx - .select({ id: chatMessageLinks.id }) - .from(chatMessageLinks) - .where( - and( - eq(chatMessageLinks.companyId, endpoint.companyId), - eq(chatMessageLinks.endpointId, endpoint.id), - eq(chatMessageLinks.conversationId, current.id), - eq(chatMessageLinks.providerMessageId, providerMessageId), - direction ? eq(chatMessageLinks.direction, direction) : undefined, - ), - ) - .limit(1) - .then((rows) => rows.length > 0); - return linkStillExists ? current : null; - } - - async function streamingReactionConversation( - database: DbOrTransaction, - endpoint: EndpointRow, - threadId: string, - ): Promise { - const candidates = await database - .selectDistinct({ - conversation: chatConversations, - sessionGeneration: chatConversations.sessionGeneration, - }) - .from(chatConversations) - .innerJoin( - chatPublications, - and( - eq(chatPublications.companyId, chatConversations.companyId), - eq(chatPublications.endpointId, chatConversations.endpointId), - eq(chatPublications.conversationId, chatConversations.id), - ), - ) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - or( - inArray(chatConversations.state, ["active", "waiting"]), - and( - eq(chatConversations.isDirectMessage, true), - eq(chatConversations.state, "completed"), - ), - ), - reactionConversationThreadCondition(endpoint.provider, threadId), - eq(chatPublications.state, "streaming"), - ), - ) - .orderBy(desc(chatConversations.sessionGeneration)) - .limit(2); - if (candidates.length !== 1) return null; - return candidates[0]!.conversation; - } - - async function lockStreamingReactionConversation( - tx: DbTransaction, - endpoint: EndpointRow, - threadId: string, - ): Promise { - const candidate = await streamingReactionConversation( - tx, - endpoint, - threadId, - ); - if (!candidate) return null; - const current = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, candidate.id), - or( - inArray(chatConversations.state, ["active", "waiting"]), - and( - eq(chatConversations.isDirectMessage, true), - eq(chatConversations.state, "completed"), - ), - ), - ), - ) - // Keep the same lock strength/order as the exact-link path above so an - // in-flight message-link insert cannot deadlock reaction admission. - .for("no key update") - .then((rows) => rows[0] ?? null); - if (!current) return null; - const stillStreaming = await tx - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.conversationId, current.id), - eq(chatPublications.state, "streaming"), - ), - ) - .limit(1) - .then((rows) => rows.length > 0); - return stillStreaming ? current : null; - } - - async function authorizeReactionConversation( - tx: DbTransaction, - endpoint: EndpointRow, - conversation: ConversationRow, - principalId: string, - ): Promise { - const resource = - 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 destinationAllowed = conversation.isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource); - if (!destinationAllowed) return false; - return (await lockCurrentPrincipalAuthorization(tx, endpoint, principalId)) - .allowed; - } - - async function handleReaction( - event: ChatSdkCallbackEvent, - runtimeContext: RuntimeContext, - ) { - const record = await runtimeCallbackRecord( - event.endpointId, - runtimeContext, - ["active"], - ); - if ( - !record || - record.endpoint.status !== "active" || - record.endpoint.provider !== event.provider || - record.endpoint.capabilities.reactions !== true || - event.event.user.isMe || - event.event.user.isBot === true || - event.event.user.isSystem - ) { - return; - } - // A DM can have a newer task generation while this reaction still belongs - // to an older linked message. Resolve its exact lineage before choosing a - // generation; never attach it to the latest task merely sharing the chat. - // A just-sent provider message can be visible before its publication/link - // transaction commits. Only that exact thread's streaming publication is - // enough evidence to stage an otherwise-unbound reaction for replay. - let conversation = await reactionConversationForMessage( - db, - record.endpoint, - event.event.threadId, - event.event.messageId, - ); - await options.reactionLinkPreflightBarrier?.(); - const plausibleStreamingConversation = conversation - ? null - : await streamingReactionConversation( - db, - record.endpoint, - event.event.threadId, - ); - if (!conversation && !plausibleStreamingConversation) { - // Settlement can commit between the first exact-link lookup and the - // streaming-publication lookup. Recheck the durable link before treating - // the callback as an arbitrary unknown message. - conversation = await reactionConversationForMessage( - db, - record.endpoint, - event.event.threadId, - event.event.messageId, - ); - if (!conversation) return; - } - - const principal = await ensurePrincipal( - record.endpoint, - event.event.user, - event.event.raw, - ); - let rawFingerprint: string; - try { - rawFingerprint = createHash("sha256") - .update(JSON.stringify(event.event.raw ?? null)) - .digest("hex") - .slice(0, 24); - } catch { - rawFingerprint = "unavailable"; - } - const eventKind: ChatEventKind = event.event.added - ? "reaction_added" - : "reaction_removed"; - const providerEventId = [ - eventKind, - durableExternalThreadIdentity(event.event.threadId), - event.event.messageId, - stableExternalPrincipalId( - record.endpoint.provider, - event.event.user, - event.event.raw, - ), - event.event.rawEmoji, - rawFingerprint, - ].join(":"); - const normalizedEvent = { - providerEventId, - kind: eventKind, - conversation: { externalThreadId: event.event.threadId }, - message: { providerMessageId: event.event.messageId }, - reaction: { - emoji: event.event.emoji.name, - rawEmoji: event.event.rawEmoji, - added: event.event.added, - }, - runtimeContext: { - generation: runtimeContext.generation, - credentialFingerprint: runtimeContext.credentialFingerprint, - }, - }; - const admitted = await db.transaction(async (tx) => { - const currentEndpoint = await runtimeCallbackEndpoint( - tx, - event.endpointId, - runtimeContext, - ["active"], - ); - if (!currentEndpoint) return false; - const linkedConversation = await lockReactionConversationForMessage( - tx, - currentEndpoint, - event.event.threadId, - event.event.messageId, - ); - const currentConversation = - linkedConversation ?? - (await lockStreamingReactionConversation( - tx, - currentEndpoint, - event.event.threadId, - )); - if ( - !currentConversation || - !(await authorizeReactionConversation( - tx, - currentEndpoint, - currentConversation, - principal.principal.id, - )) - ) { - return false; - } - const now = new Date(); - if (linkedConversation) { - const expiryCutoff = new Date(now.getTime() - REACTION_LINK_MAX_AGE_MS); - const hasOutboundLink = await tx - .select({ id: chatMessageLinks.id }) - .from(chatMessageLinks) - .where( - and( - eq(chatMessageLinks.companyId, currentEndpoint.companyId), - eq(chatMessageLinks.endpointId, currentEndpoint.id), - eq(chatMessageLinks.conversationId, linkedConversation.id), - eq(chatMessageLinks.providerMessageId, event.event.messageId), - eq(chatMessageLinks.direction, "outbound"), - ), - ) - .limit(1) - .then((rows) => rows.length > 0); - await notifyChatDeliveryWork(tx); - const reconciled = hasOutboundLink - ? await tx - .update(chatDeliveries) - .set({ - conversationId: linkedConversation.id, - state: "processed", - attempts: sql`${chatDeliveries.attempts} + 1`, - nextAttemptAt: null, - processedAt: now, - redactedError: null, - updatedAt: now, - }) - .where( - and( - eq(chatDeliveries.endpointId, currentEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - inArray(chatDeliveries.state, ["received", "retry"]), - gt(chatDeliveries.receivedAt, expiryCutoff), - lt(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS), - sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`, - sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`, - ), - ) - .returning({ id: chatDeliveries.id }) - : []; - if (reconciled.length > 0) return true; - await notifyChatDeliveryWork(tx); - const expired = await tx - .update(chatDeliveries) - .set({ - state: "filtered", - nextAttemptAt: null, - processedAt: now, - redactedError: - "Reaction target did not become linked before replay expiry", - updatedAt: now, - }) - .where( - and( - eq(chatDeliveries.endpointId, currentEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - inArray(chatDeliveries.state, ["received", "retry"]), - or( - lte(chatDeliveries.receivedAt, expiryCutoff), - gte(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS), - ), - sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`, - sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`, - ), - ) - .returning({ id: chatDeliveries.id }); - if (expired.length > 0) return false; - } - await notifyChatDeliveryWork(tx); - const inserted = await tx - .insert(chatDeliveries) - .values({ - companyId: currentEndpoint.companyId, - endpointId: currentEndpoint.id, - conversationId: linkedConversation?.id ?? null, - principalId: principal.principal.id, - providerEventId, - deduplicationKey: createHash("sha256") - .update(providerEventId) - .digest("hex"), - eventKind, - normalizedEvent, - state: linkedConversation ? "processed" : "received", - attempts: linkedConversation ? 1 : 0, - nextAttemptAt: linkedConversation - ? null - : new Date(now.getTime() + REACTION_LINK_RETRY_DELAY_MS), - processedAt: linkedConversation ? now : null, - }) - .onConflictDoNothing() - .returning({ id: chatDeliveries.id }); - if (inserted.length > 0) return true; - return tx - .select({ id: chatDeliveries.id }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.endpointId, currentEndpoint.id), - eq(chatDeliveries.providerEventId, providerEventId), - inArray(chatDeliveries.state, ["received", "retry", "processed"]), - ), - ) - .limit(1) - .then((rows) => rows.length > 0); - }); - if (admitted) { - await recordCurrentMicrosoftTeamsRoute( - record.endpoint, - runtimeContext, - event.event.threadId, - event.event.raw, - ); - } - } - async function denyExternalAction( endpoint: EndpointRow, event: ChatSdkCallbackEvent, @@ -18622,56 +14626,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } } - async function verifySlackRequestUrl(endpoint: EndpointRow, request: Request): Promise { - const callback = await inspectSlackCallback(request.clone()); - if (!callback?.isUrlVerification) return null; - const body = await request.clone().text(); - let notifyAccount = false; - // URL verification needs only the signing secret. Do not initialize the - // SDK (which calls Slack auth.test), wait for OAuth, or acquire the creation - // lease: Slack can deliver this check while app setup still owns that lease. - const response = await db.transaction(async tx => { - const [current] = await tx.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id)).for("no key update"); - if (!current || ["archived", "paused", "revoked"].includes(current.status)) return new Response("ignored", { status: 200 }); - const [connection] = await tx.select({ status: toolConnections.status, refs: toolConnections.credentialSecretRefs }).from(toolConnections) - .where(and(eq(toolConnections.id, current.connectionId), eq(toolConnections.companyId, current.companyId))); - if (!connection || connection.status === "archived") return new Response("ignored", { status: 200 }); - let secret = (await resolveCredentialRefs(current, connection.refs.filter(ref => ref.configPath.replace(/^credentials\./, "") === "signingSecret"), tx)).signingSecret; - if (!secret && current.setup.slackSetupMethod === "automatic") { - const [registration] = await tx.select().from(chatSlackRegistrations) - .where(and(eq(chatSlackRegistrations.endpointId, current.id), eq(chatSlackRegistrations.companyId, current.companyId))); - if (registration?.status !== "removed" && registration?.secretIds.signingSecret) { - secret = await secretService(tx as unknown as Db).resolveSecretValue(current.companyId, registration.secretIds.signingSecret, "latest", { - accessContext: { consumerType: "system", consumerId: `slack-registration:${current.id}`, configPath: "slack_registration.signingSecret", actorType: "system" }, - }); - } - } - if (!secret) return new Response("Slack signing secret is not ready yet", { status: 503 }); - if (!slackRequestSignatureIsValid(request, body, secret)) return new Response("Invalid signature", { status: 401 }); - const workspaceId = slackRequestWorkspaceId(body, request.headers.get("content-type") ?? ""); - if (workspaceId && current.providerAccountId && workspaceId !== current.providerAccountId) return new Response("ignored", { status: 200 }); - const observedAt = new Date(); - const setup = current.setup as InternalSetupState; - const matchesCurrentUrl = slackCallbackMatchesPublicUrl(callback.url, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${current.publicId}/slack`); - await notifyChatVerificationWork(tx); - await tx.update(chatEndpoints).set({ setup: { - ...setup, - ...(matchesCurrentUrl ? { webhookVerifiedAt: setup.webhookVerifiedAt ?? observedAt.toISOString(), - slackVerificationSigningFingerprint: createHash("sha256").update(secret).digest("hex") } : {}), - slackCallbackSurfaces: { ...setup.slackCallbackSurfaces, events: { url: callback.url, observedAt: observedAt.toISOString() } }, - ...(matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus - ? { slackAccount: { ...setup.slackAccount, verificationStatus: "pending" as const } } : {}), - } as InternalSetupState, ...(matchesCurrentUrl ? { healthMessage: "Slack Events Request URL verified" } : {}), updatedAt: observedAt }).where(eq(chatEndpoints.id, current.id)); - if (matchesCurrentUrl && !setup.webhookVerifiedAt) await logActivity(tx as unknown as Db, { companyId: current.companyId, actorType: "system", actorId: "slack-webhook", - action: "chat_endpoint.webhook_verified", entityType: "tool_connection", entityId: current.connectionId, - details: { endpointId: current.id, provider: "slack" } }); - notifyAccount = matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus; - return Response.json({ challenge: (JSON.parse(body) as { challenge: string }).challenge }); - }); - if (notifyAccount) scheduleMessageProcessing(() => slackRegistration.notifyVerified(endpoint.id)); - return response; - } - async function handleWebhook( publicId: string, provider: ChatSdkProvider, @@ -19252,7 +15206,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { // Recheck the signature under the current credential/runtime fence; // an SDK acknowledgement alone is not authentication evidence. let signingFingerprint: string | null = null; - if (!currentSetup.webhookVerifiedAt && currentSetup.slackSetupMethod === "automatic" + if (!currentSetup.webhookVerifiedAt && isProvisionedSlackSetup(currentSetup.slackSetupMethod) && callback.surface === "events" && !callback.isUrlVerification && slackCallbackMatchesPublicUrl(callback.url, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${current.publicId}/slack`)) { const payload = JSON.parse(eventBody) as { type?: string; team_id?: string; api_app_id?: string; event_id?: string; event?: { type?: string } }; @@ -19358,334 +15312,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return response; } - function normalizedPendingReaction(delivery: DeliveryRow): { - added: boolean; - emoji: string; - messageId: string; - rawEmoji: string; - runtimeContext: LifecycleRuntimeFence; - threadId: string; - } | null { - if ( - delivery.eventKind !== "reaction_added" && - delivery.eventKind !== "reaction_removed" - ) { - return null; - } - const normalized = delivery.normalizedEvent; - if ( - !normalized || - typeof normalized !== "object" || - Array.isArray(normalized) - ) { - return null; - } - const value = normalized as Record; - const conversation = value.conversation; - const message = value.message; - const reaction = value.reaction; - const runtimeContext = lifecycleRuntimeFence(delivery); - if ( - !conversation || - typeof conversation !== "object" || - Array.isArray(conversation) || - !message || - typeof message !== "object" || - Array.isArray(message) || - !reaction || - typeof reaction !== "object" || - Array.isArray(reaction) || - !runtimeContext - ) { - return null; - } - const threadId = (conversation as Record).externalThreadId; - const messageId = (message as Record).providerMessageId; - const reactionRecord = reaction as Record; - const added = reactionRecord.added; - const emoji = reactionRecord.emoji; - const rawEmoji = reactionRecord.rawEmoji; - return typeof threadId === "string" && - threadId.length > 0 && - typeof messageId === "string" && - messageId.length > 0 && - typeof added === "boolean" && - added === (delivery.eventKind === "reaction_added") && - typeof emoji === "string" && - emoji.length > 0 && - typeof rawEmoji === "string" && - rawEmoji.length > 0 - ? { added, emoji, messageId, rawEmoji, runtimeContext, threadId } - : null; - } - - async function processPendingReactionDelivery( - candidate: DeliveryRow, - ): Promise { - const reaction = normalizedPendingReaction(candidate); - if (!reaction || !candidate.principalId) { - const now = new Date(); - await db.transaction(async (tx) => { - await notifyChatDeliveryWork(tx); - return tx - .update(chatDeliveries) - .set({ - state: "failed", - nextAttemptAt: null, - processedAt: now, - redactedError: "Normalized reaction delivery is incomplete", - updatedAt: now, - }) - .where( - and( - eq(chatDeliveries.id, candidate.id), - inArray(chatDeliveries.state, ["received", "retry"]), - ), - ); - }); - return; - } - const principalId = candidate.principalId; - - await db.transaction(async (tx) => { - const currentEndpoint = await runtimeCallbackEndpoint( - tx, - candidate.endpointId, - reaction.runtimeContext, - ["active"], - ); - let conversation: ConversationRow | null = null; - let disposition: - | { kind: "processed"; conversationId: string } - | { kind: "retry" } - | { kind: "filtered"; reason: string }; - - if (!currentEndpoint) { - disposition = { - kind: "filtered", - reason: "Reaction callback belonged to an unavailable runtime", - }; - } else { - await options.reactionReplayEndpointLockBarrier?.(); - conversation = await lockReactionConversationForMessage( - tx, - currentEndpoint, - reaction.threadId, - reaction.messageId, - "outbound", - ); - if (conversation) { - await options.reactionReplayConversationLockBarrier?.(); - } - if (conversation) { - disposition = (await authorizeReactionConversation( - tx, - currentEndpoint, - conversation, - principalId, - )) - ? { kind: "processed", conversationId: conversation.id } - : { - kind: "filtered", - reason: - "Reaction destination or principal is no longer authorized", - }; - } else { - conversation = await lockStreamingReactionConversation( - tx, - currentEndpoint, - reaction.threadId, - ); - if (conversation) { - await options.reactionReplayConversationLockBarrier?.(); - } - if (!conversation) { - disposition = { - kind: "filtered", - reason: "Reaction target was not linked to a sent message", - }; - } else { - disposition = (await authorizeReactionConversation( - tx, - currentEndpoint, - conversation, - principalId, - )) - ? { kind: "retry" } - : { - kind: "filtered", - reason: - "Reaction destination or principal is no longer authorized", - }; - } - } - } - - // Endpoint, conversation, destination and identity locks are acquired - // before the delivery row so pause/revoke and replay use one lock order. - const locked = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.id, candidate.id), - inArray(chatDeliveries.state, ["received", "retry"]), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - // Authorization locks may wait behind a concurrent lifecycle mutation. - // Sample the expiry clock only after this delivery is exclusively held. - const decisionAt = new Date(); - if (!locked || !deliveryReady(locked, decisionAt)) return; - const lockedReaction = normalizedPendingReaction(locked); - if ( - !lockedReaction || - lockedReaction.runtimeContext.generation !== - reaction.runtimeContext.generation || - lockedReaction.runtimeContext.credentialFingerprint !== - reaction.runtimeContext.credentialFingerprint - ) { - disposition = { - kind: "filtered", - reason: "Reaction delivery identity changed before replay", - }; - } - - const nextAttempts = locked.attempts + 1; - const expired = - decisionAt.getTime() - locked.receivedAt.getTime() >= - REACTION_LINK_MAX_AGE_MS; - if (expired || locked.attempts >= REACTION_LINK_MAX_ATTEMPTS) { - disposition = { - kind: "filtered", - reason: "Reaction target did not become linked before replay expiry", - }; - } - if (disposition.kind === "retry" && !expired) { - if (nextAttempts < REACTION_LINK_MAX_ATTEMPTS) { - const delay = Math.min( - REACTION_LINK_RETRY_DELAY_MS * - 2 ** Math.min(Math.max(locked.attempts, 0), 5), - REACTION_LINK_MAX_DELAY_MS, - ); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "retry", - attempts: nextAttempts, - nextAttemptAt: new Date(decisionAt.getTime() + delay), - redactedError: "Waiting for the sent message link", - updatedAt: decisionAt, - }) - .where( - and( - eq(chatDeliveries.id, locked.id), - eq(chatDeliveries.state, locked.state), - eq(chatDeliveries.attempts, locked.attempts), - ), - ); - return; - } - disposition = { - kind: "filtered", - reason: "Reaction target did not become linked before retry expiry", - }; - } else if (disposition.kind === "retry") { - disposition = { - kind: "filtered", - reason: "Reaction target did not become linked before age expiry", - }; - } - - const processedAt = new Date(); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set( - disposition.kind === "processed" - ? { - conversationId: disposition.conversationId, - state: "processed", - attempts: nextAttempts, - nextAttemptAt: null, - processedAt, - redactedError: null, - updatedAt: processedAt, - } - : { - state: "filtered", - attempts: nextAttempts, - nextAttemptAt: null, - processedAt, - redactedError: disposition.reason, - updatedAt: processedAt, - }, - ) - .where( - and( - eq(chatDeliveries.id, locked.id), - eq(chatDeliveries.state, locked.state), - eq(chatDeliveries.attempts, locked.attempts), - ), - ); - }); - } - - async function processPendingReactionDeliveries( - limit: number, - onlyDeliveryId?: string, - ): Promise { - const now = new Date(); - const candidates = await db - .select() - .from(chatDeliveries) - .where( - and( - sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.provider = 'agentmail')`, - onlyDeliveryId ? eq(chatDeliveries.id, onlyDeliveryId) : undefined, - inArray(chatDeliveries.eventKind, [ - "reaction_added", - "reaction_removed", - ]), - or( - and( - eq(chatDeliveries.state, "received"), - or( - isNull(chatDeliveries.nextAttemptAt), - lte(chatDeliveries.nextAttemptAt, now), - ), - ), - and( - eq(chatDeliveries.state, "retry"), - or( - isNull(chatDeliveries.nextAttemptAt), - lte(chatDeliveries.nextAttemptAt, now), - ), - ), - ), - ), - ) - .orderBy(asc(chatDeliveries.receivedAt), asc(chatDeliveries.id)) - .limit(limit); - for (const candidate of candidates) { - try { - await processPendingReactionDelivery(candidate); - } catch (error) { - logger.warn( - { - endpointId: candidate.endpointId, - deliveryId: candidate.id, - error: redactError(error), - }, - "chat reaction-link reconciliation failed", - ); - } - } - return candidates.length; - } - /** * Reconcile a verified normalized delivery whose original request was * interrupted. Closed, credential-free provider descriptors reconstruct @@ -19919,96 +15545,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return reactionCount + rows.length; } - async function listConversations(endpointId: string) { - const record = await endpointRecord(endpointId); - if (!record) throw notFound("Chat endpoint not found"); - const conversations = await db - .select() - .from(chatConversations) - .where(eq(chatConversations.endpointId, endpointId)) - .orderBy( - desc(chatConversations.lastActivityAt), - desc(chatConversations.createdAt), - ); - if (!conversations.length) return []; - const issueRows = await db - .select({ - id: issues.id, - identifier: issues.identifier, - title: issues.title, - status: issues.status, - updatedAt: issues.updatedAt, - }) - .from(issues) - .where( - inArray( - issues.id, - conversations.map((row) => row.issueId), - ), - ); - const publications = await db - .select() - .from(chatPublications) - .where(eq(chatPublications.endpointId, endpointId)) - .orderBy(desc(chatPublications.createdAt)); - const issueById = new Map(issueRows.map((row) => [row.id, row])); - const lastPublication = new Map< - string, - typeof chatPublications.$inferSelect - >(); - for (const publication of publications) - if (!lastPublication.has(publication.conversationId)) - lastPublication.set(publication.conversationId, publication); - return conversations.map((conversation) => { - const issue = issueById.get(conversation.issueId); - const externalUrl = - record.endpoint.provider === "telegram" && conversation.isDirectMessage - ? chatProviderConversationUrl({ - provider: "telegram", - botUsername: record.endpoint.botUsername, - threadId: conversation.externalThreadId, - providerMessageId: "", - raw: { chat: { type: "private" } }, - }) - : (conversation.providerUrl ?? - (record.endpoint.provider === "slack" - ? chatProviderConversationUrl({ - provider: "slack", - providerAccountId: record.endpoint.providerAccountId, - threadId: conversation.externalThreadId, - providerMessageId: "", - }) - : null)); - return { - id: conversation.id, - companyId: conversation.companyId, - endpointId: conversation.endpointId, - resourceId: conversation.resourceId, - externalLabel: conversation.externalLabel, - externalUrl, - externalConversationId: conversation.externalConversationId, - externalThreadId: conversation.externalThreadId, - sessionGeneration: conversation.sessionGeneration, - issueId: conversation.issueId, - issueIdentifier: issue?.identifier ?? null, - issueTitle: issue?.title ?? null, - isDirectMessage: conversation.isDirectMessage, - state: - record.endpoint.provider !== "imessage-photon" && - (issue?.status === "done" || issue?.status === "cancelled") - ? "completed" - : conversation.state, - lastActivityAt: conversation.lastActivityAt?.toISOString() ?? null, - createdAt: conversation.createdAt.toISOString(), - updatedAt: ( - conversation.lastActivityAt ?? conversation.updatedAt - ).toISOString(), - lastPublicationStatus: - lastPublication.get(conversation.id)?.state ?? null, - }; - }); - } - async function recordGitHubAutomaticSkip( endpoint: EndpointRow, deliveryId: string, event: string, reason: string | undefined, runtimeContext: LifecycleRuntimeFence, ) { @@ -20036,457 +15572,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); } - async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) { - const limit = page ? page.limit + 1 : 100; - // Match the millisecond precision of the public timestamp and use a UUID - // tie-breaker so equal timestamps never skip records between pages. - const before = (date: AnyPgColumn, id: AnyPgColumn) => page?.before - ? sql`(date_trunc('milliseconds', ${date}), ${id}) < (${page.before.createdAt}::timestamptz, ${page.before.id}::uuid)` - : undefined; - const recoveryIngress = alias(chatActions, "github_recovery_ingress"); - const [ - deliveries, - publications, - slashActions, - providerEffects, - githubIngressFailures, - slackSessionActions, - githubRecoveries, - githubRecoveryScans, - ] = await Promise.all([ - db - .select() - .from(chatDeliveries) - .where(and(eq(chatDeliveries.endpointId, endpointId), before(chatDeliveries.createdAt, chatDeliveries.id))) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatDeliveries.createdAt})`), desc(chatDeliveries.id)) - .limit(limit), - db - .select() - .from(chatPublications) - .where(and(eq(chatPublications.endpointId, endpointId), before(chatPublications.createdAt, chatPublications.id))) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatPublications.createdAt})`), desc(chatPublications.id)) - .limit(limit), - db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, endpointId), - before(chatActions.createdAt, chatActions.id), - eq(chatActions.kind, "slash_task_start"), - ), - ) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) - .limit(limit), - db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, endpointId), - before(chatActions.createdAt, chatActions.id), - eq(chatActions.kind, "provider_effect"), - eq(chatActions.status, "delivery_unknown"), - ), - ) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) - .limit(limit), - db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, endpointId), - before(chatActions.createdAt, chatActions.id), - eq(chatActions.kind, "github_webhook_ingress"), - eq(chatActions.status, "failed"), - sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'false'`, - ), - ) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) - .limit(limit), - db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, endpointId), - before(chatActions.updatedAt, chatActions.id), - inArray(chatActions.kind, [ - "slack_session_sync", - "slack_session_stop", - ]), - ), - ) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id)) - .limit(limit), - db - .select({ - action: chatActions, - ingressStatus: recoveryIngress.status, - }) - .from(chatActions) - .leftJoin( - recoveryIngress, - and( - eq(recoveryIngress.companyId, chatActions.companyId), - eq(recoveryIngress.endpointId, chatActions.endpointId), - eq(recoveryIngress.kind, "github_webhook_ingress"), - sql`${recoveryIngress.providerActionId} = 'github_webhook_ingress:' || (${chatActions.payload}#>>'{original,guid}')`, - ), - ) - .where( - and( - eq(chatActions.endpointId, endpointId), - before(chatActions.updatedAt, chatActions.id), - eq(chatActions.kind, "github_webhook_recovery"), - ), - ) - .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id)) - .limit(limit), - db - .select() - .from(chatSdkState) - .where( - and( - eq(chatSdkState.endpointId, endpointId), - before(chatSdkState.updatedAt, chatSdkState.id), - eq(chatSdkState.stateKey, GITHUB_RECOVERY_STATE_KEY), - ), - ) - .limit(1), - ]); - // A provider effect can be on another page. Replay safety must still - // consider every unresolved effect associated with these deliveries. - const ambiguousEffects = deliveries.length ? await db.select({ deliveryId: chatActions.deliveryId }) - .from(chatActions).where(and( - eq(chatActions.endpointId, endpointId), - eq(chatActions.kind, "provider_effect"), - eq(chatActions.status, "delivery_unknown"), - inArray(chatActions.deliveryId, deliveries.map((row) => row.id)), - )) : []; - const ambiguousProviderEffectDeliveryIds = new Set(ambiguousEffects.map((row) => row.deliveryId)); - const transferRows = publications.length - ? await db - .select(fileTransferProjectionColumns) - .from(chatTeamsFileTransfers) - .innerJoin( - chatPublications, - and( - eq(chatPublications.id, chatTeamsFileTransfers.publicationId), - eq(chatPublications.companyId, chatTeamsFileTransfers.companyId), - eq( - chatPublications.endpointId, - chatTeamsFileTransfers.endpointId, - ), - eq( - chatPublications.conversationId, - chatTeamsFileTransfers.conversationId, - ), - ), - ) - .where( - and( - eq(chatTeamsFileTransfers.companyId, publications[0]!.companyId), - eq(chatTeamsFileTransfers.endpointId, endpointId), - inArray( - chatTeamsFileTransfers.publicationId, - publications.map((row) => row.id), - ), - ), - ) - : []; - const transfers = new Map( - transferRows.map((row) => [row.publicationId, row]), - ); - const cancellableConflicts = new Map(); - for (const transfer of transferRows) { - if (transfer.phase !== "conflict") continue; - const publication = publications.find( - (row) => row.id === transfer.publicationId, - ); - if (!publication?.conversationId) continue; - if ( - await teamsTransferProtocol().canCancelConflict({ - companyId: publication.companyId, - endpointId, - conversationId: publication.conversationId, - publicationId: publication.id, - version: transfer.version, - }) - ) - cancellableConflicts.set(publication.id, transfer.version); - } - return [ - ...deliveries.map((row) => { - const normalized = - row.normalizedEvent && - typeof row.normalizedEvent === "object" && - !Array.isArray(row.normalizedEvent) - ? (row.normalizedEvent as Record) - : {}; - const deduplication = - normalized.deduplication && - typeof normalized.deduplication === "object" && - !Array.isArray(normalized.deduplication) - ? (normalized.deduplication as Record) - : null; - const duplicateCount = - typeof deduplication?.duplicateCount === "number" - ? deduplication.duplicateCount - : 0; - const label = row.eventKind.replaceAll("_", " "); - const outcome = - row.state === "filtered" - ? "ignored" - : row.state === "processed" - ? "processed" - : row.state; - return { - id: row.id, - kind: "delivery" as const, - status: row.state, - summary: `${label} ${outcome}${duplicateCount ? ` · ${duplicateCount} duplicate${duplicateCount === 1 ? "" : "s"} ignored` : ""}`, - detail: row.redactedError, - createdAt: row.createdAt.toISOString(), - replayable: - row.state === "failed" && - Boolean(row.conversationId) && - !ambiguousProviderEffectDeliveryIds.has(row.id), - resolutionActions: [], - }; - }), - ...publications.map((row) => { - const transfer = transfers.get(row.id); - const projected = projectChatFileTransfer( - publicationSummary(row), - transfer, - ); - const payload = row.payload as Partial; - const summary = payload.progressState - ? `${payload.progressState.replaceAll("_", " ")} update` - : payload.interactionId - ? "Interactive question" - : "Response to external conversation"; - return { - id: row.id, - kind: "publication" as const, - status: projected.state, - summary, - detail: projected.redactedError ?? null, - createdAt: row.createdAt.toISOString(), - replayable: !transfer && row.state === "failed", - ...(projected.fileTransfer - ? { fileTransfer: projected.fileTransfer } - : {}), - resolutionActions: transfer - ? chatFileTransferResolutionActions( - projected, - cancellableConflicts.has(row.id) - ? { - publicationId: row.id, - version: cancellableConflicts.get(row.id)!, - } - : undefined, - ) - : row.state === "delivery_unknown" - ? (["mark_delivered", "retry_anyway", "cancel"] as const) - : [], - }; - }), - ...slashActions.map((row) => { - const recovery = slackSlashTaskRecoveryPayload(row.payload); - // Keep this GET read-only while still surfacing a crashed attempt as - // actionable. The resolution POST persists the derived quarantine - // before it applies the operator's audited decision. - const status = slackTaskStartActivityStatus(row); - return { - id: row.id, - kind: "action" as const, - actionType: "slash_task_start" as const, - status, - summary: `Slack slash-command task start ${status.replaceAll("_", " ")}`, - detail: - status === "delivery_unknown" - ? recovery - ? "Slack may have accepted the task-start message, so Paperclip will not replay it automatically. Check Slack first; an explicit retry can create a duplicate starter message and task." - : "Slack may have accepted the task-start message. This older action lacks the context required for a safe explicit retry, so check Slack and cancel it here before submitting a new command." - : status === "provider_confirmed" - ? "Slack accepted the task-start message. Paperclip is completing durable task admission without sending another Slack message." - : status === "admitting" - ? "Slack accepted the task-start message. A Paperclip worker is admitting the task without replaying the Slack send." - : status === "failed" - ? "Slack rejected the task-start message. Submit the command again to retry." - : status === "cancelled" - ? "An operator cancelled this unconfirmed task start." - : null, - createdAt: row.createdAt.toISOString(), - replayable: false, - resolutionActions: - status === "delivery_unknown" - ? recovery - ? (["retry_anyway", "cancel"] as const) - : (["cancel"] as const) - : [], - }; - }), - ...providerEffects.map((row) => { - const payload = providerEffectPayload(row.payload); - return { - id: row.id, - kind: "action" as const, - actionType: "provider_effect" as const, - status: row.status, - summary: "Provider reply delivery unknown", - detail: payload?.completeConversationId - ? "The provider may have accepted this reply, but Paperclip could not confirm it or close the task conversation. Check the provider first. Marking it delivered closes the conversation; retrying can create a duplicate message." - : "The provider may have accepted this reply, but Paperclip could not confirm it. Check the provider first. Retrying can create a duplicate message.", - createdAt: row.createdAt.toISOString(), - replayable: false, - resolutionActions: [ - "mark_delivered", - "retry_anyway", - "cancel", - ] as const, - }; - }), - ...slackSessionActions.map((row) => { - const isSync = row.kind === "slack_session_sync"; - const retrying = - row.status === "received" && - row.result?.code === "slack_session_sync_retry"; - return { - id: row.id, - kind: "action" as const, - actionType: isSync - ? ("slack_session_sync" as const) - : ("slack_session_stop" as const), - status: retrying ? "retry" : row.status, - summary: isSync ? "Slack session status" : "Slack Stop request", - detail: isSync - ? row.result?.outcome === "unavailable" - ? "Slack has not enabled native session status for this destination. Message delivery continues." - : row.result?.code === "slack_session_sync_rejected" - ? "Slack rejected the session indicator update. Check app permissions and channel access; message delivery is tracked separately." - : retrying - ? "The Slack session indicator is waiting to sync. Paperclip will not resend the response." - : row.result?.sessionStatus === "processing" - ? "Working status is refreshed automatically while the run remains active." - : null - : row.status === "processed" - ? "Paperclip stopped the work authorized by this request." - : row.status === "cancelled" - ? "No work was stopped: this request was no longer authorized or its target was no longer current." - : row.status === "failed" - ? row.result?.retryable === true - ? "The Stop request could not finish yet. Paperclip will retry against the original work only." - : "The Stop request could not be completed. Check the task's current run before trying again." - : "Paperclip is processing this Stop request against its original task and run.", - createdAt: row.updatedAt.toISOString(), - replayable: false, - resolutionActions: [], - }; - }), - ...githubIngressFailures.map((row) => { - const deliveryId = - typeof row.payload.deliveryId === "string" - ? row.payload.deliveryId - : null; - return { - id: row.id, - kind: "action" as const, - actionType: "github_webhook_ingress" as const, - status: row.status, - summary: "GitHub webhook could not be processed", - detail: deliveryId - ? `GitHub delivery ${deliveryId} was authenticated, but Paperclip could not finish processing it. Fix the connection or destination, then redeliver this delivery from the GitHub App's Recent Deliveries page.` - : "An authenticated GitHub webhook could not be processed. Fix the connection or destination, then redeliver it from the GitHub App's Recent Deliveries page.", - createdAt: row.createdAt.toISOString(), - replayable: false, - resolutionActions: [], - }; - }), - ...githubRecoveries.map(({ action: row, ingressStatus }) => ({ - id: row.id, - kind: "repair" as const, - status: - row.status === "cancelled" - ? "cancelled" - : ingressStatus - ? "received" - : "pending", - summary: - row.status === "cancelled" - ? "GitHub webhook recovery skipped" - : ingressStatus - ? "GitHub webhook received after recovery request" - : "GitHub webhook redelivery requested", - detail: - row.status === "cancelled" - ? row.result?.code === "source_changed_or_unavailable" - ? "The original comment changed or is no longer available. Paperclip did not replay its old contents." - : "This callback was not an eligible current user comment. Paperclip did not replay it." - : ingressStatus - ? "Paperclip received this callback. Its normal access checks and processing still apply." - : "Paperclip asked GitHub to resend a recent missed message. This does not yet confirm receipt or a reply. Automatic requests are limited; if it remains unanswered, check the App's Recent Deliveries and send your request again in the current conversation.", - createdAt: row.updatedAt.toISOString(), - replayable: false, - resolutionActions: [], - })), - ...githubRecoveryScans.flatMap((row) => { - const value = row.value as { outcome?: string } | null; - const detail = - value?.outcome === "history_limit_reached" - ? "Recent webhook volume exceeded automatic recovery's bounded scan. No partial history was replayed. Check the App's Recent Deliveries for missed messages." - : value?.outcome === "callback_mismatch" - ? "The GitHub App's callback no longer matches this connection. Reconnect the App to repair it; repository access will not change." - : value?.outcome === "scan_failed" - ? "Paperclip could not check GitHub's failed deliveries. The check will retry with backoff; normal callbacks are still processed." - : null; - return detail - ? [ - { - id: row.id, - kind: "health" as const, - status: "attention", - summary: "GitHub webhook recovery needs attention", - detail, - createdAt: row.updatedAt.toISOString(), - replayable: false, - resolutionActions: [], - }, - ] - : []; - }), - ] - .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)) - .slice(0, limit); - } - - async function listActivityPage(endpointId: string, limit = 25, cursor?: string) { - if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw badRequest("Activity limit must be between 1 and 100"); - let before: { createdAt: string; id: string } | undefined; - if (cursor !== undefined) { - try { - if (cursor.length > 256) throw new Error("Invalid cursor"); - const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); - if (!Array.isArray(value) || value.length !== 2 - || typeof value[0] !== "string" || new Date(value[0]).toISOString() !== value[0] - || typeof value[1] !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value[1])) throw new Error("Invalid cursor"); - before = { createdAt: value[0], id: value[1] }; - } catch { throw badRequest("Invalid activity cursor"); } - } - const rows = await listActivity(endpointId, { limit, before }); - const items = rows.slice(0, limit); - const last = items.at(-1); - return { - items, - nextCursor: rows.length > limit && last - ? Buffer.from(JSON.stringify([last.createdAt, last.id])).toString("base64url") - : null, - }; - } - async function replayDelivery(endpointId: string, deliveryId: string) { const delivery = await db .select() @@ -22678,118 +17763,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); } - async function publicationFiles( - publication: typeof chatPublications.$inferSelect, - payload: SafeChatPublicationPayload, - preparation: { teamsInlineImagesOnly?: boolean } = {}, - ): Promise { - if (!payload.attachmentIds?.length) return []; - try { - const rows = await db - .select({ - id: issueAttachments.id, - issueId: issueAttachments.issueId, - issueCommentId: issueAttachments.issueCommentId, - objectKey: assets.objectKey, - contentType: assets.contentType, - byteSize: assets.byteSize, - sha256: assets.sha256, - originalFilename: assets.originalFilename, - }) - .from(issueAttachments) - .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) - .where( - and( - eq(issueAttachments.companyId, publication.companyId), - eq(issueAttachments.issueId, publication.issueId), - inArray(issueAttachments.id, payload.attachmentIds), - ), - ); - const byId = new Map(rows.map((row) => [row.id, row])); - const orderedRows = payload.attachmentIds.map((attachmentId) => { - const row = byId.get(attachmentId); - if ( - !row || - (publication.commentId && - row.issueCommentId !== publication.commentId) || - row.byteSize <= 0 || - row.byteSize > MAX_ATTACHMENT_BYTES || - !row.objectKey.trim() || - !/^[0-9a-f]{64}$/iu.test(row.sha256) || - !isAllowedContentType(row.contentType) - ) - throw new OutboundAttachmentValidationError( - "Chat publication attachment is invalid or outside its authorized task comment", - ); - return row; - }); - // Picture messages have a different Teams contract from arbitrary files. - // Preserve the normal scope/metadata checks above, but never hydrate a - // document or oversized image just to discover it needs a task link. - if ( - preparation.teamsInlineImagesOnly && - orderedRows.some( - (row) => - row.byteSize > TEAMS_INLINE_IMAGE_MAX_BYTES || - !isTeamsInlineImageContentType(row.contentType), - ) - ) { - return []; - } - // Authorization and persisted metadata must fail definitively even when - // storage is unavailable; do not mask an invalid file as a transient outage. - if (!options.storage) - throw new OutboundAttachmentHydrationError( - "Attachment storage is unavailable for chat publication", - ); - const files: FileUpload[] = []; - for (const row of orderedRows) { - files.push( - await hydrateOutboundAttachment({ - storage: options.storage, - companyId: publication.companyId, - objectKey: row.objectKey, - byteSize: row.byteSize, - sha256: row.sha256, - filename: row.originalFilename ?? `attachment-${row.id}`, - mimeType: row.contentType, - }), - ); - } - return files; - } catch (error) { - if ( - isOutboundAttachmentHydrationError(error) || - isOutboundAttachmentValidationError(error) - ) { - throw error; - } - throw new OutboundAttachmentHydrationError( - "Chat publication attachment preparation failed", - error, - ); - } - } - - function discordMarkdownAttachment(text: string): FileUpload { - return { - data: Buffer.from(text, "utf8"), - filename: "paperclip-response.md", - mimeType: "text/markdown; charset=utf-8", - }; - } - - function telegramMarkdownAttachment(text: string): Attachment { - const data = Buffer.from(text, "utf8"); - return { - data, - mimeType: "text/markdown; charset=utf-8", - name: "paperclip-response.md", - size: data.byteLength, - type: "file", - }; - } - async function recordExactOutboundPublicationLink( tx: DbOrTransaction, input: { @@ -26107,8 +21080,32 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return get(endpointId); } + 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"); + const { principal } = await ensurePrincipal(record.endpoint, { + userId: String(user.id), userName: typeof user.name === "string" ? user.name : String(user.id), + fullName: typeof user.real_name === "string" ? user.real_name : String(user.id), isBot: false, isMe: false, + }); + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.id}`}, 0))`); + const membership = (await tx.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, record.endpoint.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, actor.userId))).for("update"))[0]; + if (membership?.status !== "active" || membership.membershipRole === "viewer") throw forbidden("The signed-in Paperclip account cannot use this connection"); + const links = await tx.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.principalId, principal.id))).for("update"); + if (links.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) || links.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link", { code: "chat_identity_link_conflict" }); + if (links.some(link => link.endpointId === endpointId && link.status === "linked")) return; + await tx.insert(chatIdentityLinks).values({ companyId: record.endpoint.companyId, endpointId, principalId: principal.id, + paperclipUserId: actor.userId, status: "linked", confirmedAt: new Date() }) + .onConflictDoUpdate({ target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], set: { + paperclipUserId: actor.userId, status: "linked", confirmationTokenHash: null, expiresAt: null, confirmedAt: new Date(), revokedAt: null, updatedAt: new Date(), + } }); + await lease.assertOwned(tx); + }); + }; + const slackRegistration = slackChatRegistrationService(db, { - publicOrigin: getPublicBaseUrl, webhookOrigin: getWebhookPublicBaseUrl, fetch: fetchImpl, renderAvatar: options.renderSlackAvatar, + publicOrigin: getPublicBaseUrl, webhookOrigin: getWebhookPublicBaseUrl, fetch: fetchImpl, renderAvatar: options.renderSlackAvatar, managed: options.slackManaged, withLock: async (endpointId, work) => { const record = await endpointRecord(endpointId); if (!record) throw notFound("Chat endpoint not found"); @@ -26136,29 +21133,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId), eq(chatIdentityLinks.status, "linked"), eq(chatIdentityLinks.paperclipUserId, account.paperclipUserId), eq(chatExternalPrincipals.externalId, account.externalUserId), eq(chatExternalPrincipals.providerAccountId, record.endpoint.providerAccountId!))); return Boolean(link); }, - 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"); - const { principal } = await ensurePrincipal(record.endpoint, { - userId: String(user.id), userName: typeof user.name === "string" ? user.name : String(user.id), - fullName: typeof user.real_name === "string" ? user.real_name : String(user.id), isBot: false, isMe: false, - }); - await db.transaction(async tx => { - await lease.assertOwned(tx); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.id}`}, 0))`); - const membership = (await tx.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, record.endpoint.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, actor.userId))).for("update"))[0]; - if (membership?.status !== "active" || membership.membershipRole === "viewer") throw forbidden("The signed-in Paperclip account cannot use this connection"); - const links = await tx.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.principalId, principal.id))).for("update"); - if (links.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) || links.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link", { code: "chat_identity_link_conflict" }); - if (links.some(link => link.endpointId === endpointId && link.status === "linked")) return; - await tx.insert(chatIdentityLinks).values({ companyId: record.endpoint.companyId, endpointId, principalId: principal.id, - paperclipUserId: actor.userId, status: "linked", confirmedAt: new Date() }) - .onConflictDoUpdate({ target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], set: { - paperclipUserId: actor.userId, status: "linked", confirmationTokenHash: null, expiresAt: null, confirmedAt: new Date(), revokedAt: null, updatedAt: new Date(), - } }); - await lease.assertOwned(tx); - }); - }, + linkInstaller: linkSlackAccount, configure: async (endpointId, credentials, actor, lease) => { const record = await endpointRecord(endpointId); if (!record) throw notFound("Chat endpoint not found"); @@ -26167,6 +21142,29 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }, lease, actor.userId); }, }); + const { verifySlackRequestUrl } = createSlackCallbackVerification(db, { resolveCredentialRefs, getWebhookPublicBaseUrl, scheduleMessageProcessing, slackRegistration }); + + const sendSlackTestMessage = slackTestMessageService(db, { + fetch: fetchImpl, + withLock: async (endpointId, work) => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + return withCredentialMutationLease(record.endpoint, work); + }, + runtimeBotToken: async endpointId => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + return (await resolveCredentials(record.endpoint)).botToken; + }, + linkInstaller: linkSlackAccount, + ensureAccount: async (endpointId, user) => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const { principal } = await ensurePrincipal(record.endpoint, { userId: String(user.id), userName: String(user.name ?? user.id), fullName: String(user.real_name ?? user.id), isBot: false, isMe: false }); + return principal.id; + }, + createLinkIntent, + }); const githubRegistration = githubChatRegistrationService(db, { publicOrigin: () => getPublicBaseUrl(), webhookOrigin: () => getWebhookPublicBaseUrl(), fetch: fetchImpl, @@ -26345,6 +21343,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { voice, authorizeFastResponse, slackRegistration, + sendSlackTestMessage, saveGitHubSetupProgress: async (endpointId: string, stage: NonNullable["stage"]) => { const record = await endpointRecord(endpointId); if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found"); diff --git a/server/src/services/chat-channels/activity.ts b/server/src/services/chat-channels/activity.ts new file mode 100644 index 00000000000..0a9824ae577 --- /dev/null +++ b/server/src/services/chat-channels/activity.ts @@ -0,0 +1,616 @@ +import { providerEffectPayload } from "./provider-effects.js"; + +import { slackSlashTaskRecoveryPayload } from "./slack/task-start.js"; +import { GITHUB_RECOVERY_STATE_KEY } from "./github/webhook-ingress.js"; + +import { + and, + desc, + eq, + inArray, + sql, +} from "drizzle-orm"; +import { alias, type AnyPgColumn } from "drizzle-orm/pg-core"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatDeliveries, + chatPublications, + chatSdkState, + chatTeamsFileTransfers, + issues, +} from "@paperclipai/db"; +import type { + ChatPublicationSummary, + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { + teamsFileTransferService, +} from "../chat-teams-file-transfers.js"; + +import { + chatFileTransferResolutionActions, + projectChatFileTransfer, +} from "../chat-publication-batches.js"; + +import { + badRequest, + notFound, +} from "../../errors.js"; + +import { chatProviderConversationUrl } from "../chat-provider-links.js"; + +import type { EndpointRecord } from "./runtime-types.js"; +import { slackTaskStartActivityStatus } from "./slack/task-start.js"; +import { fileTransferProjectionColumns } from "./microsoft-teams/file-transfer-projection.js"; + +function publicationSummary( + row: typeof chatPublications.$inferSelect, +): ChatPublicationSummary { + return { + id: row.id, + state: row.state, + providerUrl: row.providerUrl, + attempts: row.attempts, + redactedError: row.redactedError, + nextAttemptAt: row.nextAttemptAt?.toISOString() ?? null, + publishedAt: row.publishedAt?.toISOString() ?? null, + }; +} +export { publicationSummary }; + +type ChatActivityOptions = { + endpointRecord: (endpointId: string) => Promise; + teamsTransferProtocol: () => Pick, "canCancelConflict">; +}; + +export function createChatActivity(db: Db, options: ChatActivityOptions) { + const { endpointRecord, teamsTransferProtocol } = options; + + + async function listConversations(endpointId: string) { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const conversations = await db + .select() + .from(chatConversations) + .where(eq(chatConversations.endpointId, endpointId)) + .orderBy( + desc(chatConversations.lastActivityAt), + desc(chatConversations.createdAt), + ); + if (!conversations.length) return []; + const issueRows = await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + updatedAt: issues.updatedAt, + }) + .from(issues) + .where( + inArray( + issues.id, + conversations.map((row) => row.issueId), + ), + ); + const publications = await db + .select() + .from(chatPublications) + .where(eq(chatPublications.endpointId, endpointId)) + .orderBy(desc(chatPublications.createdAt)); + const issueById = new Map(issueRows.map((row) => [row.id, row])); + const lastPublication = new Map< + string, + typeof chatPublications.$inferSelect + >(); + for (const publication of publications) + if (!lastPublication.has(publication.conversationId)) + lastPublication.set(publication.conversationId, publication); + return conversations.map((conversation) => { + const issue = issueById.get(conversation.issueId); + const externalUrl = + record.endpoint.provider === "telegram" && conversation.isDirectMessage + ? chatProviderConversationUrl({ + provider: "telegram", + botUsername: record.endpoint.botUsername, + threadId: conversation.externalThreadId, + providerMessageId: "", + raw: { chat: { type: "private" } }, + }) + : (conversation.providerUrl ?? + (record.endpoint.provider === "slack" + ? chatProviderConversationUrl({ + provider: "slack", + providerAccountId: record.endpoint.providerAccountId, + threadId: conversation.externalThreadId, + providerMessageId: "", + }) + : null)); + return { + id: conversation.id, + companyId: conversation.companyId, + endpointId: conversation.endpointId, + resourceId: conversation.resourceId, + externalLabel: conversation.externalLabel, + externalUrl, + externalConversationId: conversation.externalConversationId, + externalThreadId: conversation.externalThreadId, + sessionGeneration: conversation.sessionGeneration, + issueId: conversation.issueId, + issueIdentifier: issue?.identifier ?? null, + issueTitle: issue?.title ?? null, + isDirectMessage: conversation.isDirectMessage, + state: + record.endpoint.provider !== "imessage-photon" && + (issue?.status === "done" || issue?.status === "cancelled") + ? "completed" + : conversation.state, + lastActivityAt: conversation.lastActivityAt?.toISOString() ?? null, + createdAt: conversation.createdAt.toISOString(), + updatedAt: ( + conversation.lastActivityAt ?? conversation.updatedAt + ).toISOString(), + lastPublicationStatus: + lastPublication.get(conversation.id)?.state ?? null, + }; + }); + } + + async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) { + const limit = page ? page.limit + 1 : 100; + // Match the millisecond precision of the public timestamp and use a UUID + // tie-breaker so equal timestamps never skip records between pages. + const before = (date: AnyPgColumn, id: AnyPgColumn) => page?.before + ? sql`(date_trunc('milliseconds', ${date}), ${id}) < (${page.before.createdAt}::timestamptz, ${page.before.id}::uuid)` + : undefined; + const recoveryIngress = alias(chatActions, "github_recovery_ingress"); + const [ + deliveries, + publications, + slashActions, + providerEffects, + githubIngressFailures, + slackSessionActions, + githubRecoveries, + githubRecoveryScans, + ] = await Promise.all([ + db + .select() + .from(chatDeliveries) + .where(and(eq(chatDeliveries.endpointId, endpointId), before(chatDeliveries.createdAt, chatDeliveries.id))) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatDeliveries.createdAt})`), desc(chatDeliveries.id)) + .limit(limit), + db + .select() + .from(chatPublications) + .where(and(eq(chatPublications.endpointId, endpointId), before(chatPublications.createdAt, chatPublications.id))) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatPublications.createdAt})`), desc(chatPublications.id)) + .limit(limit), + db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, endpointId), + before(chatActions.createdAt, chatActions.id), + eq(chatActions.kind, "slash_task_start"), + ), + ) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) + .limit(limit), + db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, endpointId), + before(chatActions.createdAt, chatActions.id), + eq(chatActions.kind, "provider_effect"), + eq(chatActions.status, "delivery_unknown"), + ), + ) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) + .limit(limit), + db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, endpointId), + before(chatActions.createdAt, chatActions.id), + eq(chatActions.kind, "github_webhook_ingress"), + eq(chatActions.status, "failed"), + sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'false'`, + ), + ) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) + .limit(limit), + db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, endpointId), + before(chatActions.updatedAt, chatActions.id), + inArray(chatActions.kind, [ + "slack_session_sync", + "slack_session_stop", + ]), + ), + ) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id)) + .limit(limit), + db + .select({ + action: chatActions, + ingressStatus: recoveryIngress.status, + }) + .from(chatActions) + .leftJoin( + recoveryIngress, + and( + eq(recoveryIngress.companyId, chatActions.companyId), + eq(recoveryIngress.endpointId, chatActions.endpointId), + eq(recoveryIngress.kind, "github_webhook_ingress"), + sql`${recoveryIngress.providerActionId} = 'github_webhook_ingress:' || (${chatActions.payload}#>>'{original,guid}')`, + ), + ) + .where( + and( + eq(chatActions.endpointId, endpointId), + before(chatActions.updatedAt, chatActions.id), + eq(chatActions.kind, "github_webhook_recovery"), + ), + ) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id)) + .limit(limit), + db + .select() + .from(chatSdkState) + .where( + and( + eq(chatSdkState.endpointId, endpointId), + before(chatSdkState.updatedAt, chatSdkState.id), + eq(chatSdkState.stateKey, GITHUB_RECOVERY_STATE_KEY), + ), + ) + .limit(1), + ]); + // A provider effect can be on another page. Replay safety must still + // consider every unresolved effect associated with these deliveries. + const ambiguousEffects = deliveries.length ? await db.select({ deliveryId: chatActions.deliveryId }) + .from(chatActions).where(and( + eq(chatActions.endpointId, endpointId), + eq(chatActions.kind, "provider_effect"), + eq(chatActions.status, "delivery_unknown"), + inArray(chatActions.deliveryId, deliveries.map((row) => row.id)), + )) : []; + const ambiguousProviderEffectDeliveryIds = new Set(ambiguousEffects.map((row) => row.deliveryId)); + const transferRows = publications.length + ? await db + .select(fileTransferProjectionColumns) + .from(chatTeamsFileTransfers) + .innerJoin( + chatPublications, + and( + eq(chatPublications.id, chatTeamsFileTransfers.publicationId), + eq(chatPublications.companyId, chatTeamsFileTransfers.companyId), + eq( + chatPublications.endpointId, + chatTeamsFileTransfers.endpointId, + ), + eq( + chatPublications.conversationId, + chatTeamsFileTransfers.conversationId, + ), + ), + ) + .where( + and( + eq(chatTeamsFileTransfers.companyId, publications[0]!.companyId), + eq(chatTeamsFileTransfers.endpointId, endpointId), + inArray( + chatTeamsFileTransfers.publicationId, + publications.map((row) => row.id), + ), + ), + ) + : []; + const transfers = new Map( + transferRows.map((row) => [row.publicationId, row]), + ); + const cancellableConflicts = new Map(); + for (const transfer of transferRows) { + if (transfer.phase !== "conflict") continue; + const publication = publications.find( + (row) => row.id === transfer.publicationId, + ); + if (!publication?.conversationId) continue; + if ( + await teamsTransferProtocol().canCancelConflict({ + companyId: publication.companyId, + endpointId, + conversationId: publication.conversationId, + publicationId: publication.id, + version: transfer.version, + }) + ) + cancellableConflicts.set(publication.id, transfer.version); + } + return [ + ...deliveries.map((row) => { + const normalized = + row.normalizedEvent && + typeof row.normalizedEvent === "object" && + !Array.isArray(row.normalizedEvent) + ? (row.normalizedEvent as Record) + : {}; + const deduplication = + normalized.deduplication && + typeof normalized.deduplication === "object" && + !Array.isArray(normalized.deduplication) + ? (normalized.deduplication as Record) + : null; + const duplicateCount = + typeof deduplication?.duplicateCount === "number" + ? deduplication.duplicateCount + : 0; + const label = row.eventKind.replaceAll("_", " "); + const outcome = + row.state === "filtered" + ? "ignored" + : row.state === "processed" + ? "processed" + : row.state; + return { + id: row.id, + kind: "delivery" as const, + status: row.state, + summary: `${label} ${outcome}${duplicateCount ? ` · ${duplicateCount} duplicate${duplicateCount === 1 ? "" : "s"} ignored` : ""}`, + detail: row.redactedError, + createdAt: row.createdAt.toISOString(), + replayable: + row.state === "failed" && + Boolean(row.conversationId) && + !ambiguousProviderEffectDeliveryIds.has(row.id), + resolutionActions: [], + }; + }), + ...publications.map((row) => { + const transfer = transfers.get(row.id); + const projected = projectChatFileTransfer( + publicationSummary(row), + transfer, + ); + const payload = row.payload as Partial; + const summary = payload.progressState + ? `${payload.progressState.replaceAll("_", " ")} update` + : payload.interactionId + ? "Interactive question" + : "Response to external conversation"; + return { + id: row.id, + kind: "publication" as const, + status: projected.state, + summary, + detail: projected.redactedError ?? null, + createdAt: row.createdAt.toISOString(), + replayable: !transfer && row.state === "failed", + ...(projected.fileTransfer + ? { fileTransfer: projected.fileTransfer } + : {}), + resolutionActions: transfer + ? chatFileTransferResolutionActions( + projected, + cancellableConflicts.has(row.id) + ? { + publicationId: row.id, + version: cancellableConflicts.get(row.id)!, + } + : undefined, + ) + : row.state === "delivery_unknown" + ? (["mark_delivered", "retry_anyway", "cancel"] as const) + : [], + }; + }), + ...slashActions.map((row) => { + const recovery = slackSlashTaskRecoveryPayload(row.payload); + // Keep this GET read-only while still surfacing a crashed attempt as + // actionable. The resolution POST persists the derived quarantine + // before it applies the operator's audited decision. + const status = slackTaskStartActivityStatus(row); + return { + id: row.id, + kind: "action" as const, + actionType: "slash_task_start" as const, + status, + summary: `Slack slash-command task start ${status.replaceAll("_", " ")}`, + detail: + status === "delivery_unknown" + ? recovery + ? "Slack may have accepted the task-start message, so Paperclip will not replay it automatically. Check Slack first; an explicit retry can create a duplicate starter message and task." + : "Slack may have accepted the task-start message. This older action lacks the context required for a safe explicit retry, so check Slack and cancel it here before submitting a new command." + : status === "provider_confirmed" + ? "Slack accepted the task-start message. Paperclip is completing durable task admission without sending another Slack message." + : status === "admitting" + ? "Slack accepted the task-start message. A Paperclip worker is admitting the task without replaying the Slack send." + : status === "failed" + ? "Slack rejected the task-start message. Submit the command again to retry." + : status === "cancelled" + ? "An operator cancelled this unconfirmed task start." + : null, + createdAt: row.createdAt.toISOString(), + replayable: false, + resolutionActions: + status === "delivery_unknown" + ? recovery + ? (["retry_anyway", "cancel"] as const) + : (["cancel"] as const) + : [], + }; + }), + ...providerEffects.map((row) => { + const payload = providerEffectPayload(row.payload); + return { + id: row.id, + kind: "action" as const, + actionType: "provider_effect" as const, + status: row.status, + summary: "Provider reply delivery unknown", + detail: payload?.completeConversationId + ? "The provider may have accepted this reply, but Paperclip could not confirm it or close the task conversation. Check the provider first. Marking it delivered closes the conversation; retrying can create a duplicate message." + : "The provider may have accepted this reply, but Paperclip could not confirm it. Check the provider first. Retrying can create a duplicate message.", + createdAt: row.createdAt.toISOString(), + replayable: false, + resolutionActions: [ + "mark_delivered", + "retry_anyway", + "cancel", + ] as const, + }; + }), + ...slackSessionActions.map((row) => { + const isSync = row.kind === "slack_session_sync"; + const retrying = + row.status === "received" && + row.result?.code === "slack_session_sync_retry"; + return { + id: row.id, + kind: "action" as const, + actionType: isSync + ? ("slack_session_sync" as const) + : ("slack_session_stop" as const), + status: retrying ? "retry" : row.status, + summary: isSync ? "Slack session status" : "Slack Stop request", + detail: isSync + ? row.result?.outcome === "unavailable" + ? "Slack has not enabled native session status for this destination. Message delivery continues." + : row.result?.code === "slack_session_sync_rejected" + ? "Slack rejected the session indicator update. Check app permissions and channel access; message delivery is tracked separately." + : retrying + ? "The Slack session indicator is waiting to sync. Paperclip will not resend the response." + : row.result?.sessionStatus === "processing" + ? "Working status is refreshed automatically while the run remains active." + : null + : row.status === "processed" + ? "Paperclip stopped the work authorized by this request." + : row.status === "cancelled" + ? "No work was stopped: this request was no longer authorized or its target was no longer current." + : row.status === "failed" + ? row.result?.retryable === true + ? "The Stop request could not finish yet. Paperclip will retry against the original work only." + : "The Stop request could not be completed. Check the task's current run before trying again." + : "Paperclip is processing this Stop request against its original task and run.", + createdAt: row.updatedAt.toISOString(), + replayable: false, + resolutionActions: [], + }; + }), + ...githubIngressFailures.map((row) => { + const deliveryId = + typeof row.payload.deliveryId === "string" + ? row.payload.deliveryId + : null; + return { + id: row.id, + kind: "action" as const, + actionType: "github_webhook_ingress" as const, + status: row.status, + summary: "GitHub webhook could not be processed", + detail: deliveryId + ? `GitHub delivery ${deliveryId} was authenticated, but Paperclip could not finish processing it. Fix the connection or destination, then redeliver this delivery from the GitHub App's Recent Deliveries page.` + : "An authenticated GitHub webhook could not be processed. Fix the connection or destination, then redeliver it from the GitHub App's Recent Deliveries page.", + createdAt: row.createdAt.toISOString(), + replayable: false, + resolutionActions: [], + }; + }), + ...githubRecoveries.map(({ action: row, ingressStatus }) => ({ + id: row.id, + kind: "repair" as const, + status: + row.status === "cancelled" + ? "cancelled" + : ingressStatus + ? "received" + : "pending", + summary: + row.status === "cancelled" + ? "GitHub webhook recovery skipped" + : ingressStatus + ? "GitHub webhook received after recovery request" + : "GitHub webhook redelivery requested", + detail: + row.status === "cancelled" + ? row.result?.code === "source_changed_or_unavailable" + ? "The original comment changed or is no longer available. Paperclip did not replay its old contents." + : "This callback was not an eligible current user comment. Paperclip did not replay it." + : ingressStatus + ? "Paperclip received this callback. Its normal access checks and processing still apply." + : "Paperclip asked GitHub to resend a recent missed message. This does not yet confirm receipt or a reply. Automatic requests are limited; if it remains unanswered, check the App's Recent Deliveries and send your request again in the current conversation.", + createdAt: row.updatedAt.toISOString(), + replayable: false, + resolutionActions: [], + })), + ...githubRecoveryScans.flatMap((row) => { + const value = row.value as { outcome?: string } | null; + const detail = + value?.outcome === "history_limit_reached" + ? "Recent webhook volume exceeded automatic recovery's bounded scan. No partial history was replayed. Check the App's Recent Deliveries for missed messages." + : value?.outcome === "callback_mismatch" + ? "The GitHub App's callback no longer matches this connection. Reconnect the App to repair it; repository access will not change." + : value?.outcome === "scan_failed" + ? "Paperclip could not check GitHub's failed deliveries. The check will retry with backoff; normal callbacks are still processed." + : null; + return detail + ? [ + { + id: row.id, + kind: "health" as const, + status: "attention", + summary: "GitHub webhook recovery needs attention", + detail, + createdAt: row.updatedAt.toISOString(), + replayable: false, + resolutionActions: [], + }, + ] + : []; + }), + ] + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)) + .slice(0, limit); + } + + async function listActivityPage(endpointId: string, limit = 25, cursor?: string) { + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw badRequest("Activity limit must be between 1 and 100"); + let before: { createdAt: string; id: string } | undefined; + if (cursor !== undefined) { + try { + if (cursor.length > 256) throw new Error("Invalid cursor"); + const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + if (!Array.isArray(value) || value.length !== 2 + || typeof value[0] !== "string" || new Date(value[0]).toISOString() !== value[0] + || typeof value[1] !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value[1])) throw new Error("Invalid cursor"); + before = { createdAt: value[0], id: value[1] }; + } catch { throw badRequest("Invalid activity cursor"); } + } + const rows = await listActivity(endpointId, { limit, before }); + const items = rows.slice(0, limit); + const last = items.at(-1); + return { + items, + nextCursor: rows.length > limit && last + ? Buffer.from(JSON.stringify([last.createdAt, last.id])).toString("base64url") + : null, + }; + } + return { listConversations, listActivity, listActivityPage }; +} diff --git a/server/src/services/chat-channels/discord/gateway-events.ts b/server/src/services/chat-channels/discord/gateway-events.ts new file mode 100644 index 00000000000..e283fdc8630 --- /dev/null +++ b/server/src/services/chat-channels/discord/gateway-events.ts @@ -0,0 +1,253 @@ +import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence, InternalSetupState } from "../runtime-types.js"; + +import { notifyChatEndpointWork } from "../../chat-work-notifications.js"; + +import { + and, + eq, + inArray, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatEndpointResources, + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, +} from "@paperclipai/shared"; + +import { + type DiscordGatewayCallbackEvent, +} from "../../chat-sdk-runtime.js"; + +import { + type ChatProviderLifecycleEffect, +} from "../../chat-provider-lifecycle.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type DiscordGatewayEventsOptions = { + ensureDiscordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => Promise; + discordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => boolean; + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + applyProviderLifecycleEffect: (endpoint: EndpointRow, effect: ChatProviderLifecycleEffect, context?: RuntimeContext) => Promise; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + discordGatewayEventBarrier?: (event: DiscordGatewayCallbackEvent) => Promise; +}; + +export function createDiscordGatewayEvents(db: Db, options: DiscordGatewayEventsOptions) { + const { ensureDiscordGatewayRuntimeIsCurrent, discordGatewayRuntimeIsCurrent, runtimeCallbackRecord, runtimeCallbackEndpoint, applyProviderLifecycleEffect, runtimeGeneration } = options; + + + function discordGatewayFailureDetail( + event: DiscordGatewayCallbackEvent["event"], + ): string { + if (event.type === "guild_removed") { + return "Discord bot was removed from the configured server"; + } + if (event.type === "guild_unavailable") { + return "Discord server is temporarily unavailable; reconnecting"; + } + if (event.type === "disconnected") { + return `Discord Gateway disconnected${event.code === undefined ? "" : ` (code ${event.code})`}; reconnecting`; + } + if (event.type === "failure") { + const code = event.error.code ?? event.error.status; + return `Discord Gateway failed${code === undefined ? "" : ` (${String(code).slice(0, 64)})`}; ${event.fatal ? "credentials or privileged intents need attention" : "reconnecting"}`; + } + return "Discord Gateway connection needs attention"; + } + + function handleDiscordGatewayEvent( + callback: DiscordGatewayCallbackEvent, + context: RuntimeContext, + ): Promise { + const pending = (context.discordGatewayEventQueue ?? Promise.resolve()) + .catch(() => undefined) + .then(() => processDiscordGatewayEvent(callback, context)); + context.discordGatewayEventQueue = pending; + return pending; + } + + async function processDiscordGatewayEvent( + callback: DiscordGatewayCallbackEvent, + context: RuntimeContext, + ): Promise { + if ( + !(await ensureDiscordGatewayRuntimeIsCurrent( + callback.endpointId, + context, + )) + ) + return; + if (callback.sequence <= (context.discordGatewaySequence ?? 0)) return; + context.discordGatewaySequence = callback.sequence; + await options.discordGatewayEventBarrier?.(callback); + const record = await runtimeCallbackRecord(callback.endpointId, context, [ + "verifying", + "active", + "attention", + ]); + if (!record || record.endpoint.provider !== "discord") return; + const event = callback.event; + if (event.type === "channel_removed") { + await applyProviderLifecycleEffect( + record.endpoint, + { + kind: "resource", + provider: "discord", + providerEventId: `gateway:channel_removed:${event.channelId}`, + providerResourceId: event.channelId, + resourceType: "channel", + label: event.label?.trim() || event.channelId, + availability: "removed", + metadata: { source: "discord_gateway" }, + }, + context, + ); + return; + } + + await db.transaction(async (tx) => { + const current = await runtimeCallbackEndpoint( + tx, + callback.endpointId, + context, + ["verifying", "active", "attention"], + ); + if ( + !current || + current.provider !== "discord" || + !discordGatewayRuntimeIsCurrent(callback.endpointId, context) + ) { + return; + } + const now = new Date(); + if (event.type === "connecting") { + await tx + .update(chatEndpoints) + .set({ + healthMessage: "Discord Gateway connecting", + lastError: null, + lastEventAt: now, + updatedAt: now, + }) + .where(eq(chatEndpoints.id, current.id)); + await tx + .update(toolConnections) + .set({ + healthStatus: "unchecked", + healthMessage: "Discord Gateway connecting", + lastError: null, + healthCheckedAt: now, + updatedAt: now, + }) + .where(eq(toolConnections.id, current.connectionId)); + return; + } + if (event.type === "ready" || event.type === "guild_available") { + const gatewayOwnedAttention = + current.status === "attention" && + current.healthMessage?.startsWith("Discord ") === true; + if (current.status === "attention" && !gatewayOwnedAttention) return; + const status = + current.setup.step === "complete" ? "active" : "verifying"; + if (status !== current.status) await notifyChatEndpointWork(tx); + await tx + .update(chatEndpoints) + .set({ + status, + healthMessage: + status === "active" + ? "Connected" + : "Waiting for a test conversation", + lastError: null, + lastEventAt: now, + updatedAt: now, + }) + .where(eq(chatEndpoints.id, current.id)); + await tx + .update(toolConnections) + .set({ + status: "active", + enabled: true, + healthStatus: "healthy", + healthMessage: "Connected", + lastError: null, + healthCheckedAt: now, + updatedAt: now, + }) + .where(eq(toolConnections.id, current.connectionId)); + return; + } + + const detail = discordGatewayFailureDetail(event); + const transientGuildOutage = event.type === "guild_unavailable"; + const terminal = + event.type === "guild_removed" || + ((event.type === "failure" || event.type === "disconnected") && + event.fatal); + await tx + .update(chatEndpoints) + .set({ + status: transientGuildOutage + ? current.status + : event.type === "guild_removed" + ? "revoked" + : "attention", + healthMessage: detail, + lastError: detail, + lastEventAt: now, + ...(terminal + ? { + setup: { + ...current.setup, + runtimeGeneration: runtimeGeneration(current.setup) + 1, + } as InternalSetupState, + } + : {}), + updatedAt: now, + }) + .where(eq(chatEndpoints.id, current.id)); + await tx + .update(toolConnections) + .set({ + status: terminal ? "disabled" : "active", + enabled: !terminal, + healthStatus: event.type === "guild_removed" ? "failed" : "degraded", + healthMessage: detail, + lastError: detail, + healthCheckedAt: now, + updatedAt: now, + }) + .where(eq(toolConnections.id, current.connectionId)); + if (event.type === "guild_removed") { + await tx + .update(chatEndpointResources) + .set({ availability: "unavailable", updatedAt: now }) + .where( + and( + eq(chatEndpointResources.companyId, current.companyId), + eq(chatEndpointResources.endpointId, current.id), + ), + ); + await tx + .update(chatConversations) + .set({ state: "unavailable", updatedAt: now }) + .where( + and( + eq(chatConversations.companyId, current.companyId), + eq(chatConversations.endpointId, current.id), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ); + } + }); + } + return { handleDiscordGatewayEvent }; +} diff --git a/server/src/services/chat-channels/discord/messages.ts b/server/src/services/chat-channels/discord/messages.ts index f61b2a2e8ef..61582abed26 100644 --- a/server/src/services/chat-channels/discord/messages.ts +++ b/server/src/services/chat-channels/discord/messages.ts @@ -1,3 +1,4 @@ +import type { FileUpload } from "chat"; import { createHash } from "node:crypto"; @@ -28,3 +29,11 @@ export function discordLifecycleFilesDigest(raw: unknown): string { : []; return createHash("sha256").update(JSON.stringify(projection)).digest("hex"); } + +export function discordMarkdownAttachment(text: string): FileUpload { + return { + data: Buffer.from(text, "utf8"), + filename: "paperclip-response.md", + mimeType: "text/markdown; charset=utf-8", + }; + } diff --git a/server/src/services/chat-channels/discord/root-threads.ts b/server/src/services/chat-channels/discord/root-threads.ts new file mode 100644 index 00000000000..1772ce375bb --- /dev/null +++ b/server/src/services/chat-channels/discord/root-threads.ts @@ -0,0 +1,570 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "../runtime-types.js"; + +import { notifyChatDeliveryWork } from "../../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + inArray, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatDeliveries, + chatEndpointResources, + chatEndpoints, + chatExternalPrincipals, + companyMemberships, +} from "@paperclipai/db"; + +import { + conflict, +} from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; + +import { + DiscordAdapterCompatibilityError, + type ChatSdkMessageCallbackEvent, + type DiscordRootMentionAdmissionEvent, +} from "../../chat-sdk-runtime.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import type { + Message, + Thread, +} from "chat"; + +type DeliveryRow = typeof chatDeliveries.$inferSelect; + +type DiscordRootThreadsOptions = { + ensureDiscordGatewayRuntimeIsCurrent: (endpointId: string, context: RuntimeContext) => Promise; + withCredentialMutationLease: ReturnType; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; linkedDenied: boolean }>; + 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) => Promise; + redactError: (error: unknown) => string; + scheduleConversationDrain: (endpointId: string, threadId: string) => void; + lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null; + runtimeFor: (endpoint: EndpointRow) => Promise; + DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS: number; + discordRootThreadTransportBarrier?: () => Promise; +}; + +export function createDiscordRootThreads(db: Db, options: DiscordRootThreadsOptions) { + const { ensureDiscordGatewayRuntimeIsCurrent, withCredentialMutationLease, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, processMessage, redactError, scheduleConversationDrain, lifecycleRuntimeFence, runtimeFor, DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS } = options; + + + async function admitDiscordRootMention( + configuredEndpoint: EndpointRow, + event: DiscordRootMentionAdmissionEvent, + context: RuntimeContext, + ): Promise { + if ( + !(await ensureDiscordGatewayRuntimeIsCurrent( + configuredEndpoint.id, + context, + )) + ) { + return false; + } + return withCredentialMutationLease( + configuredEndpoint, + async (credentialLease) => { + const threadId = `discord:${event.guildId}:${event.channelId}:${event.messageId}`; + const providerEventId = `${threadId}:${event.messageId}`; + const allowed = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const endpoint = await runtimeCallbackEndpoint( + tx, + configuredEndpoint.id, + context, + ["verifying", "active"], + ); + const existing = await tx + .select({ state: chatDeliveries.state }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, configuredEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + ), + ) + .then((rows) => rows[0] ?? null); + if ( + existing && + ["processed", "filtered", "failed"].includes(existing.state) + ) + return false; + + let reason: string | null = null; + if (!endpoint || endpoint.providerAccountId !== event.guildId) { + reason = "Connection is not active"; + } + const resource = endpoint + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.type, "channel"), + eq( + chatEndpointResources.providerResourceId, + event.channelId, + ), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const enabledCount = + endpoint?.status === "verifying" + ? await tx + .select({ count: sql`count(*)::int` }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.type, "channel"), + eq(chatEndpointResources.enabled, true), + eq(chatEndpointResources.availability, "available"), + ), + ) + .then((rows) => rows[0]?.count ?? 0) + : 0; + const setupDestination = + endpoint?.status === "verifying" && + enabledCount === 0 && + resource?.availability === "available"; + if ( + !reason && + !( + endpoint?.allowGroupChats && + resource?.availability === "available" && + (resource.enabled || setupDestination) + ) + ) { + reason = "Destination is not enabled in Paperclip"; + } + + if (!reason && endpoint) { + const principal = await tx + .select({ id: chatExternalPrincipals.id }) + .from(chatExternalPrincipals) + .where( + and( + eq(chatExternalPrincipals.companyId, endpoint.companyId), + eq(chatExternalPrincipals.provider, "discord"), + eq(chatExternalPrincipals.providerAccountId, event.guildId), + eq(chatExternalPrincipals.externalId, event.userId), + ), + ) + .then((rows) => rows[0] ?? null); + let allowed: boolean; + let linkedDenied = false; + if (principal) { + const authorization = await lockCurrentPrincipalAuthorization( + tx, + endpoint, + principal.id, + ); + allowed = authorization.allowed; + linkedDenied = authorization.linkedDenied; + } else if (!endpoint.allowUnlinkedPeople) { + allowed = false; + } else if (!endpoint.sponsorUserId) { + allowed = true; + } else { + const sponsor = 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); + allowed = + sponsor?.status === "active" && + sponsor.membershipRole !== "viewer"; + } + if (!allowed) { + reason = linkedDenied + ? "Linked Paperclip account is not currently permitted" + : endpoint.allowUnlinkedPeople + ? "Endpoint sponsor can no longer authorize external guests" + : "External identity must be linked to a Paperclip account"; + } + } + if (!reason) { + await credentialLease.assertOwned(tx); + return true; + } + + const filteredAt = new Date(); + const normalizedEvent = { + providerEventId, + kind: "mention", + trigger: "mention", + resource: { + type: "channel", + providerResourceId: event.channelId, + }, + conversation: { externalThreadId: threadId }, + message: { providerMessageId: event.messageId }, + filtering: { contentRetained: false, providerThreadCreated: false }, + }; + await notifyChatDeliveryWork(tx); + await tx + .insert(chatDeliveries) + .values({ + companyId: configuredEndpoint.companyId, + endpointId: configuredEndpoint.id, + providerEventId, + deduplicationKey: createHash("sha256") + .update(providerEventId) + .digest("hex"), + eventKind: "mention", + normalizedEvent, + state: "filtered", + redactedError: reason, + processedAt: filteredAt, + }) + .onConflictDoUpdate({ + target: [ + chatDeliveries.endpointId, + chatDeliveries.providerEventId, + ], + set: { + normalizedEvent, + principalId: null, + state: "filtered", + nextAttemptAt: null, + redactedError: reason, + processedAt: filteredAt, + updatedAt: filteredAt, + }, + }); + await tx + .update(chatEndpoints) + .set({ lastEventAt: filteredAt, updatedAt: filteredAt }) + .where(eq(chatEndpoints.id, configuredEndpoint.id)); + await credentialLease.assertOwned(tx); + return false; + }); + if (!allowed || !event.message || !context.endpointRuntime) { + return false; + } + const thread = context.endpointRuntime.thread(event.threadId); + await processMessage( + configuredEndpoint, + thread, + event.message, + "mention", + true, + null, + context, + undefined, + null, + true, + undefined, + true, + ); + const staged = await db + .select({ state: chatDeliveries.state }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, configuredEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + ), + ) + .then((rows) => rows[0] ?? null); + if ( + !staged || + !["received", "retry", "processing"].includes(staged.state) + ) { + return false; + } + + await options.discordRootThreadTransportBarrier?.(); + await credentialLease.assertOwned(); + if ( + !(await ensureDiscordGatewayRuntimeIsCurrent( + configuredEndpoint.id, + context, + )) + ) { + return false; + } + try { + await context.endpointRuntime.ensureDiscordRootThread({ + channelId: event.channelId, + content: event.message.text, + messageId: event.messageId, + }); + } catch (error) { + // The durable provisional receipt owns recovery. Do not let a + // transient REST failure escape the Gateway event handler or ask the + // adapter to create the same thread outside Paperclip's lifecycle + // fence. + logger.warn( + { + endpointId: configuredEndpoint.id, + error: redactError(error), + providerEventId, + }, + "Discord root thread creation will be retried from the durable receipt", + ); + return false; + } + const promoted = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await runtimeCallbackEndpoint( + tx, + configuredEndpoint.id, + context, + ["verifying", "active"], + ); + if (!current) return false; + await notifyChatDeliveryWork(tx); + const [row] = await tx + .update(chatDeliveries) + .set({ + state: "received", + normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`, + nextAttemptAt: null, + redactedError: null, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.endpointId, configuredEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + eq(chatDeliveries.state, "retry"), + sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, + ), + ) + .returning({ id: chatDeliveries.id }); + await credentialLease.assertOwned(tx); + return Boolean(row); + }); + if (promoted) + scheduleConversationDrain(configuredEndpoint.id, threadId); + // Paperclip created the provider thread inside its credential/reach + // lease. Returning false prevents the adapter from creating it again; + // the durable drain owns the remaining task mutation. + return false; + }, + ); + } + + function discordRootThreadPending( + delivery: Pick, + ): boolean { + return ( + Boolean(delivery.normalizedEvent) && + typeof delivery.normalizedEvent === "object" && + !Array.isArray(delivery.normalizedEvent) && + (delivery.normalizedEvent as { providerThreadPending?: unknown }) + .providerThreadPending === true + ); + } + + async function reconcileDiscordRootThread( + endpoint: EndpointRow, + delivery: DeliveryRow, + threadId: string, + ): Promise { + try { + const normalized = delivery.normalizedEvent as { + message?: { providerMessageId?: unknown; text?: unknown }; + resource?: { providerResourceId?: unknown }; + }; + const discordThreadParts = threadId.split(":"); + const channelId = + discordThreadParts[0] === "discord" && discordThreadParts.length >= 4 + ? discordThreadParts[2] + : normalized.resource?.providerResourceId; + const messageId = normalized.message?.providerMessageId; + if (typeof channelId !== "string" || typeof messageId !== "string") { + throw new DiscordAdapterCompatibilityError( + "the provisional root receipt is missing its channel or message id", + ); + } + const runtimeFence = lifecycleRuntimeFence(delivery); + if (!runtimeFence) { + throw new DiscordAdapterCompatibilityError( + "the provisional root receipt is missing its runtime fence", + ); + } + return await withCredentialMutationLease( + endpoint, + async (credentialLease) => { + const currentEndpoint = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await runtimeCallbackEndpoint( + tx, + endpoint.id, + runtimeFence, + ["verifying", "active"], + ); + await credentialLease.assertOwned(tx); + return current; + }); + if (!currentEndpoint) { + throw conflict( + "Discord root thread credentials or connection state changed before recovery", + { code: "chat_endpoint_runtime_superseded" }, + ); + } + const endpointRuntime = await runtimeFor(currentEndpoint); + await credentialLease.assertOwned(); + await endpointRuntime.ensureDiscordRootThread({ + channelId, + messageId, + content: + typeof normalized.message?.text === "string" + ? normalized.message.text + : "Paperclip task", + }); + return await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await runtimeCallbackEndpoint( + tx, + endpoint.id, + runtimeFence, + ["verifying", "active"], + ); + if (!current) return false; + await notifyChatDeliveryWork(tx); + const [promoted] = await tx + .update(chatDeliveries) + .set({ + state: "received", + normalizedEvent: sql`${chatDeliveries.normalizedEvent} - 'providerThreadPending'`, + nextAttemptAt: null, + redactedError: null, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, delivery.id), + eq(chatDeliveries.state, "retry"), + sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, + ), + ) + .returning({ id: chatDeliveries.id }); + await credentialLease.assertOwned(tx); + return Boolean(promoted); + }); + }, + ); + } catch (error) { + const disposition = classifyChatPublicationError( + error, + delivery.attempts + 1, + ); + const terminal = + disposition.kind === "resource_unavailable" || + disposition.kind === "failed"; + const retryAfterMs = + disposition.kind === "retry" + ? disposition.retryAfterMs + : DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS; + const now = new Date(); + await db.transaction(async (tx) => { + if (disposition.kind === "resource_unavailable") { + const fence = lifecycleRuntimeFence(delivery); + const normalized = delivery.normalizedEvent as { + resource?: { providerResourceId?: unknown }; + }; + const providerResourceId = normalized.resource?.providerResourceId; + const current = fence + ? await runtimeCallbackEndpoint(tx, endpoint.id, fence, [ + "verifying", + "active", + "attention", + ]) + : null; + const resource = + current && typeof providerResourceId === "string" + ? await tx + .select({ id: chatEndpointResources.id }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, current.companyId), + eq(chatEndpointResources.endpointId, current.id), + eq( + chatEndpointResources.providerResourceId, + providerResourceId, + ), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + if (resource) { + await tx + .update(chatEndpointResources) + .set({ availability: "unavailable", updatedAt: now }) + .where(eq(chatEndpointResources.id, resource.id)); + await tx + .update(chatConversations) + .set({ state: "unavailable", updatedAt: now }) + .where( + and( + eq(chatConversations.companyId, current!.companyId), + eq(chatConversations.endpointId, current!.id), + eq(chatConversations.resourceId, resource.id), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ); + } + } + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: terminal ? "filtered" : "retry", + attempts: delivery.attempts + 1, + nextAttemptAt: terminal + ? null + : new Date(now.getTime() + retryAfterMs), + processedAt: terminal ? now : null, + redactedError: terminal + ? "Discord thread creation was not confirmed" + : redactError(error), + updatedAt: now, + }) + .where( + and( + eq(chatDeliveries.id, delivery.id), + eq(chatDeliveries.state, "retry"), + sql`${chatDeliveries.normalizedEvent}->>'providerThreadPending' = 'true'`, + ), + ); + }); + return false; + } + } + return { admitDiscordRootMention, discordRootThreadPending, reconcileDiscordRootThread }; +} diff --git a/server/src/services/chat-channels/inbound-attachments.ts b/server/src/services/chat-channels/inbound-attachments.ts new file mode 100644 index 00000000000..4be37251b61 --- /dev/null +++ b/server/src/services/chat-channels/inbound-attachments.ts @@ -0,0 +1,674 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "./runtime-types.js"; + +import type { ChatEndpointRuntime } from "../voice/voice-runtime.js"; + +import { takePhotonCompanion } from "../photon/attachments.js"; + +import { HEIF_CONTENT_TYPES, photonHeifPreview, validatePhotonImage } from "../photon/media.js"; + +import { PhotonError } from "../photon/cloud.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + inArray, + isNull, + ne, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + assets, + chatActions, + chatConversations, + chatDeliveries, + issueComments, + issueAttachments, +} from "@paperclipai/db"; + +import { + isAllowedContentType, + MAX_ATTACHMENT_BYTES, + normalizeContentType, + normalizeUploadAttachmentContentType, +} from "../../attachment-types.js"; + +import { + hasTelegramMediaProvenance, + identifyTelegramMedia, + telegramMediaNeedsIdentification, +} from "../chat-telegram-media-intake.js"; + +import { + forbidden, +} from "../../errors.js"; +import { logger } from "../../middleware/logger.js"; + +import type { StorageService } from "../../storage/types.js"; + +import { issueService } from "../issues.js"; + +import { isExternalChatWaitAuthorizationContention } from "../native-runtime/chat-attachment-reuse.js"; + +import { + GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS, + GitHubAttachmentUnavailableError, + prepareGitHubPublicAttachment, +} from "../chat-github-attachments.js"; + +import type { + Attachment, +} from "chat"; + +type DeliveryRow = typeof chatDeliveries.$inferSelect; + +type InboundAttachmentsOptions = { + storage?: StorageService; + authorizeInboundWakeup: (tx: DbOrTransaction, action: typeof chatActions.$inferSelect) => Promise<{ delivery: DeliveryRow; conversation: typeof chatConversations.$inferSelect }>; + lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null; + sanitizeFilename: (value: string | undefined) => string | null; + issuesSvc: Pick, "createAttachment">; + isExternalActionAuthorizationChange: (error: unknown) => boolean; + redactError: (error: unknown) => string; +}; + +export function createInboundAttachments(db: Db, options: InboundAttachmentsOptions) { + const { authorizeInboundWakeup, lifecycleRuntimeFence, sanitizeFilename, issuesSvc, isExternalActionAuthorizationChange, redactError } = options; + + + async function ingestAttachments(input: { + endpoint: EndpointRow; + endpointRuntime: ChatEndpointRuntime; + deliveryId: string; + issueId: string; + issueCommentId: string; + attachments: Attachment[]; + attachmentLimitOmissions?: number; + unavailableReferenceCount?: number; + actorUserId: string | null; + }): Promise<{ + storedIds: string[]; + omissionReasons: Record; + }> { + const omissionReasons: Record = {}; + const omit = (reason: string, count = 1) => { + omissionReasons[reason] = (omissionReasons[reason] ?? 0) + count; + }; + const boundedAttachments = input.attachments.slice(0, 20); + const photonDerivatives = new Map(); + const originalPhotonAttachments = new Map(); + if ( + input.endpoint.provider === "microsoft-teams" && + input.unavailableReferenceCount + ) { + // A channel/group reference is not a download capability. Still tell + // the exact current-turn consumer that those bytes are unavailable, + // including when there are no ingestible attachments at all. + omit("download_unavailable", input.unavailableReferenceCount); + } + if ( + ["github", "imessage-photon"].includes(input.endpoint.provider) && + input.attachmentLimitOmissions + ) { + omit("attachment_limit", input.attachmentLimitOmissions); + } + if (input.attachments.length > boundedAttachments.length) { + omit( + "attachment_limit", + input.attachments.length - boundedAttachments.length, + ); + } + if (!options.storage) { + if (boundedAttachments.length) + omit("storage_unavailable", boundedAttachments.length); + return { storedIds: [], omissionReasons }; + } + const existingByFingerprint = new Map(); + const existingAttachments = await db + .select({ + id: issueAttachments.id, + byteSize: assets.byteSize, + contentType: assets.contentType, + originalFilename: assets.originalFilename, + sha256: assets.sha256, + }) + .from(issueAttachments) + .innerJoin( + assets, + and( + eq(assets.companyId, issueAttachments.companyId), + eq(assets.id, issueAttachments.assetId), + ), + ) + .where( + and( + eq(issueAttachments.companyId, input.endpoint.companyId), + eq(issueAttachments.issueId, input.issueId), + eq(issueAttachments.issueCommentId, input.issueCommentId), + ), + ); + for (const existing of existingAttachments) { + const fingerprint = JSON.stringify([ + existing.sha256, + existing.byteSize, + existing.contentType, + existing.originalFilename, + ]); + const ids = existingByFingerprint.get(fingerprint) ?? []; + ids.push(existing.id); + existingByFingerprint.set(fingerprint, ids); + } + const storedIds: string[] = []; + const withCurrentAttachmentAuthorization = async ( + work: (tx: DbTransaction) => Promise, + enforceUnchangedSource = false, + telegramMedia?: Attachment, + ): Promise => { + for (let attempt = 0; ; attempt++) { + try { + return await db.transaction(async (tx) => { + const action = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, input.endpoint.companyId), + eq(chatActions.endpointId, input.endpoint.id), + eq(chatActions.deliveryId, input.deliveryId), + eq(chatActions.kind, "inbound_wakeup"), + ), + ) + .limit(1) + .then((rows) => rows[0]); + if ( + !action || + !["preparing", "issued", "processing", "processed"].includes( + action.status, + ) || + action.payload.issueId !== input.issueId || + action.payload.commentId !== input.issueCommentId + ) + throw forbidden( + "The attachment's current chat admission is unavailable", + { code: "chat_action_authorization_changed" }, + ); + const current = await authorizeInboundWakeup(tx, action); + // Delivery settlement/replay can update these receipts without + // taking the issue lock. Lock in delivery -> action order only + // after policy authorization; NOWAIT breaks cross-worker lock + // inversions and the outer loop retries the whole transaction. + const lockedDelivery = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, input.endpoint.companyId), + eq(chatDeliveries.id, input.deliveryId), + ), + ) + .for("update", { noWait: true }) + .limit(1) + .then((rows) => rows[0]); + const lockedAction = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, input.endpoint.companyId), + eq(chatActions.id, action.id), + ), + ) + .for("update", { noWait: true }) + .limit(1) + .then((rows) => rows[0]); + const scopeKeys = [ + "version", + "issueId", + "commentId", + "agentId", + "sessionGeneration", + "requestedByActorType", + "requestedByActorId", + ] as const; + if ( + current.delivery.state !== "processing" || + !lockedDelivery || + !lockedAction || + lockedDelivery.state !== "processing" || + lockedDelivery.endpointId !== action.endpointId || + lockedDelivery.conversationId !== action.conversationId || + lockedDelivery.principalId !== action.principalId || + lockedDelivery.attempts !== current.delivery.attempts || + JSON.stringify(lifecycleRuntimeFence(lockedDelivery)) !== + JSON.stringify(lifecycleRuntimeFence(current.delivery)) || + lockedAction.endpointId !== action.endpointId || + lockedAction.deliveryId !== action.deliveryId || + lockedAction.conversationId !== action.conversationId || + lockedAction.principalId !== action.principalId || + lockedAction.kind !== "inbound_wakeup" || + !["preparing", "issued", "processing", "processed"].includes( + lockedAction.status, + ) || + scopeKeys.some( + (key) => lockedAction.payload[key] !== action.payload[key], + ) + ) + throw forbidden( + "The attachment's current chat admission is unavailable", + { code: "chat_action_authorization_changed" }, + ); + if (enforceUnchangedSource) { + if (telegramMedia) { + const normalized = lockedDelivery.normalizedEvent as { + principal?: { externalId?: string }; + message?: { + providerMessageId?: string; + attachments?: Array<{ recovery?: unknown }>; + }; + }; + const fence = lifecycleRuntimeFence(lockedDelivery); + const descriptor = + input.endpointRuntime.attachmentRecoveryDescriptor( + telegramMedia, + { + threadId: current.conversation.externalThreadId, + messageId: normalized.message?.providerMessageId ?? "", + principalExternalId: normalized.principal?.externalId, + runtimeGeneration: fence?.generation, + credentialFingerprint: fence?.credentialFingerprint, + }, + ); + if ( + descriptor?.locator.kind !== "telegram_media" || + !normalized.message?.attachments?.some((item) => { + const retained = item.recovery as { + version?: unknown; + provider?: unknown; + locator?: Record; + attachment?: Record; + } | null; + return ( + retained?.version === 1 && + retained.provider === "telegram" && + Object.entries(descriptor.locator).every( + ([key, value]) => retained.locator?.[key] === value, + ) && + Object.entries(descriptor.attachment).every( + ([key, value]) => retained.attachment?.[key] === value, + ) + ); + }) + ) + throw forbidden( + "The media's admitted source is unavailable", + { code: "chat_action_authorization_changed" }, + ); + } + const [source] = await tx + .select({ id: issueComments.id }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.endpoint.companyId), + eq(issueComments.issueId, input.issueId), + eq(issueComments.id, input.issueCommentId), + isNull(issueComments.deletedAt), + sql`${issueComments.updatedAt} = ${issueComments.createdAt}`, + ), + ) + .for("share", { noWait: true }); + // Lifecycle admission precedes its conversation drain. Pending + // source changes already invalidate this image download, even + // when the append-only original comment is still untouched. + const [lifecycle] = await tx + .select({ id: chatDeliveries.id }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, input.endpoint.companyId), + eq(chatDeliveries.endpointId, input.endpoint.id), + or( + isNull(chatDeliveries.conversationId), + eq( + chatDeliveries.conversationId, + current.conversation.id, + ), + ), + ne(chatDeliveries.state, "filtered"), + inArray(chatDeliveries.eventKind, [ + "message_updated", + "message_deleted", + "message_restored", + ]), + sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(lifecycleRuntimeFence(lockedDelivery))}::jsonb`, + sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${lockedDelivery.providerEventId}`, + ), + ) + .limit(1); + if (!source || lifecycle) + throw forbidden( + "The image's admitted source changed before attachment registration", + { + code: "chat_action_authorization_changed", + }, + ); + } + return await work(tx); + }); + } catch (error) { + if (!isExternalChatWaitAuthorizationContention(error) || attempt >= 4) + throw error; + // Retry the complete short transaction; no network/storage work or + // backoff occurs while governance rows are locked. + await new Promise((resolve) => + setTimeout(resolve, 20 * (attempt + 1)), + ); + } + } + }; + // One shared deadline bounds the sequential batch; an expired batch never + // starts another GitHub request. Other providers retain their own policy. + const githubBatchSignal = + input.endpoint.provider === "github" + ? AbortSignal.timeout(GITHUB_ATTACHMENT_BATCH_TIMEOUT_MS) + : undefined; + // This new image-only lane shares one budget across token acquisition and + // downloads. DB/storage commit semantics remain explicit and unchanged. + const teamsInlineBatchSignal = + input.endpoint.provider === "microsoft-teams" + ? AbortSignal.timeout(10_000) + : undefined; + for (let attachment of boundedAttachments) { + const teamsInlineImage = + input.endpoint.provider === "microsoft-teams" && + input.endpointRuntime.attachmentRecoveryDescriptor(attachment)?.locator + .kind === "teams_inline_image"; + const telegramMedia = + input.endpoint.provider === "telegram" && + hasTelegramMediaProvenance(attachment); + const sourceBoundMedia = + teamsInlineImage || + telegramMedia || + input.endpoint.provider === "imessage-photon"; + const requireCurrentAttachmentAuthorization = + input.endpoint.provider === "github" || sourceBoundMedia; + try { + if (teamsInlineImage && teamsInlineBatchSignal?.aborted) { + omit("download_unavailable"); + continue; + } + // GitHub's anonymized upload URLs carry no trustworthy MIME metadata. + // Resolve public bytes only here, after durable comment admission, and + // then apply the same storage/type policy as every native attachment. + if (input.endpoint.provider === "github") { + await withCurrentAttachmentAuthorization(async () => {}); + attachment = await prepareGitHubPublicAttachment( + attachment, + githubBatchSignal, + async (request, signal) => { + await withCurrentAttachmentAuthorization(async () => {}); + return ( + (await input.endpointRuntime.resolveGitHubAttachmentComment?.( + request, + signal, + )) ?? null + ); + }, + ); + } + if ( + requireCurrentAttachmentAuthorization && + input.endpoint.provider !== "github" + ) + await withCurrentAttachmentAuthorization( + async () => {}, + sourceBoundMedia, + telegramMedia ? attachment : undefined, + ); + if ( + attachment.size !== undefined && + attachment.size > MAX_ATTACHMENT_BYTES + ) { + omit("declared_too_large"); + continue; + } + if (!attachment.fetchData) { + omit("download_unavailable"); + continue; + } + const originalFilename = sanitizeFilename(attachment.name); + let contentType = normalizeUploadAttachmentContentType({ + contentType: normalizeContentType( + attachment.mimeType ?? "application/octet-stream", + ), + originalFilename, + isAllowedContentType, + }); + // Reject a provider-declared type before allocating the downloaded + // payload. The actual byte length is checked again after the adapter's + // bounded fetch contract resolves. + // A filename may infer an Office MIME for ordinary documents, but it + // must not bypass byte identification for native media without MIME. + const identifyTelegram = + telegramMedia && + telegramMediaNeedsIdentification(attachment) && + normalizeContentType( + attachment.mimeType ?? "application/octet-stream", + ) === "application/octet-stream"; + if (!isAllowedContentType(contentType) && !identifyTelegram) { + omit("unsupported_type"); + continue; + } + const fetched = teamsInlineImage + ? await input.endpointRuntime.fetchTeamsInlineImage( + attachment, + teamsInlineBatchSignal!, + ) + : await attachment.fetchData(); + const body = Buffer.isBuffer(fetched) ? fetched : Buffer.from(fetched); + if (body.length === 0) { + omit("empty_download"); + continue; + } + if (body.length > MAX_ATTACHMENT_BYTES) { + omit("downloaded_too_large"); + continue; + } + if (identifyTelegram) { + contentType = + identifyTelegramMedia(attachment, body) ?? + "application/octet-stream"; + if (!isAllowedContentType(contentType)) { + omit("unsupported_type"); + continue; + } + } + const photonSourceHash = createHash("sha256") + .update(body) + .digest("hex"); + if (input.endpoint.provider === "imessage-photon") { + await validatePhotonImage(body, contentType); + const companion = takePhotonCompanion(body); + if (companion?.unavailable) omit("companion_unavailable"); + else if (companion) { + const video: Attachment = { type: "video", name: companion.fileName, mimeType: companion.mimeType, size: companion.data.length, fetchData: async () => companion.data }; + photonDerivatives.set(video, { source: attachment, sourceHash: photonSourceHash, kind: "live_photo_video" }); + boundedAttachments.push(video); + } + if ( + HEIF_CONTENT_TYPES.has(contentType) && + isAllowedContentType("image/jpeg") + ) { + try { + const preview = await photonHeifPreview(body); + const derivative: Attachment = { + type: "image", + name: `${originalFilename} (JPEG preview).jpg`, + mimeType: "image/jpeg", + size: preview.length, + fetchData: async () => preview, + }; + photonDerivatives.set(derivative, { source: attachment, sourceHash: photonSourceHash, kind: "heif_jpeg_preview" }); + boundedAttachments.push(derivative); + } catch { + omit("preview_unavailable"); + } + } + } + const fingerprint = JSON.stringify([ + photonSourceHash, + body.length, + contentType, + originalFilename, + ]); + const existingIds = existingByFingerprint.get(fingerprint); + const existingId = existingIds?.shift(); + if (requireCurrentAttachmentAuthorization) { + // Revoke during either API resolution or the anonymous byte download + // must prevent storage and attachment registration for that input. + await withCurrentAttachmentAuthorization( + async () => {}, + sourceBoundMedia, + telegramMedia ? attachment : undefined, + ); + } + const registerPhotonProvenance = async (attachmentId: string) => { + if (input.endpoint.provider !== "imessage-photon") return; + const relation = photonDerivatives.get(attachment); + if (!relation) { + originalPhotonAttachments.set(attachment, attachmentId); + return; + } + const originalAttachmentId = + originalPhotonAttachments.get(relation.source); + if (!originalAttachmentId) + throw new Error("Photon related attachment source is missing"); + await db + .insert(chatActions) + .values({ + companyId: input.endpoint.companyId, + endpointId: input.endpoint.id, + deliveryId: input.deliveryId, + kind: relation.kind === "heif_jpeg_preview" ? "attachment_derivative" : "attachment_companion", + providerActionId: `photon-${relation.kind}:${input.deliveryId}:${originalAttachmentId}`, + payload: { + originalAttachmentId, + derivativeAttachmentId: attachmentId, + originalSha256: relation.sourceHash, + derivativeSha256: photonSourceHash, + kind: relation.kind, + }, + status: "processed", + }) + .onConflictDoNothing(); + }; + if (existingId) { + storedIds.push(existingId); + await registerPhotonProvenance(existingId); + continue; + } + const stored = await options.storage.putFile({ + companyId: input.endpoint.companyId, + namespace: `issues/${input.issueId}`, + originalFilename, + contentType, + body, + }); + const registration = { + issueId: input.issueId, + issueCommentId: input.issueCommentId, + provider: stored.provider, + objectKey: stored.objectKey, + contentType: stored.contentType, + byteSize: stored.byteSize, + sha256: stored.sha256, + originalFilename: stored.originalFilename, + createdByUserId: input.actorUserId, + }; + let row; + try { + row = requireCurrentAttachmentAuthorization + ? await withCurrentAttachmentAuthorization( + (tx) => + issueService(tx as unknown as Db).createAttachment( + registration, + ), + sourceBoundMedia, + telegramMedia ? attachment : undefined, + ) + : await issuesSvc.createAttachment(registration); + } catch (error) { + // An explicit authorization denial happens before registration. Do + // not delete on ambiguous database errors which may have committed. + if ( + requireCurrentAttachmentAuthorization && + isExternalActionAuthorizationChange(error) + ) + await options.storage + .deleteObject(input.endpoint.companyId, stored.objectKey) + .catch(() => undefined); + throw error; + } + storedIds.push(row.id); + await registerPhotonProvenance(row.id); + } catch (error) { + if (isExternalActionAuthorizationChange(error)) throw error; + if ( + error instanceof PhotonError && + ["attachment_not_ready", "network", "quota", "credentials"].includes( + error.code, + ) + ) + throw error; + // Use the closed current-input omission vocabulary consumed by native + // prompts; provider-specific diagnostics remain redacted log codes. + omit( + error instanceof GitHubAttachmentUnavailableError + ? "download_unavailable" + : "processing_failed", + ); + // A malformed or unavailable provider attachment must not strand the + // durable text delivery. The rejected file is intentionally omitted; + // the delivery remains auditable through its normalized attachment + // metadata without persisting provider credentials or download URLs. + logger.warn( + { + endpointId: input.endpoint.id, + issueId: input.issueId, + ...(error instanceof GitHubAttachmentUnavailableError + ? { + deliveryId: input.deliveryId, + attachmentDiagnosticCode: error.code, + } + : {}), + attachmentName: sanitizeFilename(attachment.name), + error: redactError(error), + }, + "external chat attachment was rejected", + ); + } + } + return { storedIds, omissionReasons }; + } + + function attachmentOmissionDetail(result: { + omissionReasons: Record; + }): string | null { + const entries = Object.entries(result.omissionReasons).filter( + ([, count]) => count > 0, + ); + const omitted = entries.reduce((total, [, count]) => total + count, 0); + if (!omitted) return null; + const reasons = entries + .map(([reason, count]) => `${reason.replaceAll("_", " ")}: ${count}`) + .join(", "); + return `${omitted} external attachment${omitted === 1 ? " was" : "s were"} omitted (${reasons})`; + } + return { ingestAttachments, attachmentOmissionDetail }; +} diff --git a/server/src/services/chat-channels/microsoft-teams/file-transfer-projection.ts b/server/src/services/chat-channels/microsoft-teams/file-transfer-projection.ts new file mode 100644 index 00000000000..34365a46184 --- /dev/null +++ b/server/src/services/chat-channels/microsoft-teams/file-transfer-projection.ts @@ -0,0 +1,18 @@ +import { sql } from "drizzle-orm"; +import { chatTeamsFileTransfers } from "@paperclipai/db"; + +export const fileTransferProjectionColumns = { + publicationId: chatTeamsFileTransfers.publicationId, + phase: chatTeamsFileTransfers.phase, + version: chatTeamsFileTransfers.version, + filename: chatTeamsFileTransfers.filename, + expiresAt: chatTeamsFileTransfers.expiresAt, + consentMessageId: chatTeamsFileTransfers.consentMessageId, + responseActivityId: chatTeamsFileTransfers.responseActivityId, + fileInfoMessageId: chatTeamsFileTransfers.fileInfoMessageId, + operatorConfirmed: sql` + coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,schema}', '') = 'paperclip.teams.file-resolution.v1' + and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,action}', '') = 'mark_delivered' + and coalesce(${chatTeamsFileTransfers.privateState}#>>'{resolution,fromPhase}', '') = 'file_info_unknown' + `, +}; diff --git a/server/src/services/chat-channels/outbound-attachments.ts b/server/src/services/chat-channels/outbound-attachments.ts new file mode 100644 index 00000000000..0f2e3a8b02e --- /dev/null +++ b/server/src/services/chat-channels/outbound-attachments.ts @@ -0,0 +1,294 @@ +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + inArray, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + assets, + chatPublications, + issueAttachments, +} from "@paperclipai/db"; +import type { + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { + isAllowedContentType, + MAX_ATTACHMENT_BYTES, +} from "../../attachment-types.js"; + +import type { StorageService } from "../../storage/types.js"; + +import { + isTeamsInlineImageContentType, + TEAMS_INLINE_IMAGE_MAX_BYTES, +} from "../chat-teams-inline-images.js"; + +import type { + FileUpload, +} from "chat"; + +const OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS = 10_000; + +class OutboundAttachmentHydrationError extends Error { + readonly code = "CHAT_ATTACHMENT_HYDRATION_FAILED"; + + constructor(message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "OutboundAttachmentHydrationError"; + } +} + +class OutboundAttachmentValidationError extends Error { + readonly code = "CHAT_ATTACHMENT_VALIDATION_FAILED"; + + constructor(message: string) { + super(message); + this.name = "OutboundAttachmentValidationError"; + } +} + +function isOutboundAttachmentHydrationError( + error: unknown, +): error is OutboundAttachmentHydrationError { + return error instanceof OutboundAttachmentHydrationError; +} + +function isOutboundAttachmentValidationError( + error: unknown, +): error is OutboundAttachmentValidationError { + return error instanceof OutboundAttachmentValidationError; +} + +/** + * Read one already-authorized attachment before any provider transport starts. + * The database digest, rather than mutable object-store metadata, is the + * integrity boundary. Both acquisition and streaming are bounded so a stuck + * storage backend cannot hold the publication lane indefinitely. + */ +export async function hydrateOutboundAttachment(input: { + storage: StorageService; + companyId: string; + objectKey: string; + byteSize: number; + sha256: string; + filename: string; + mimeType: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = + typeof input.timeoutMs === "number" && + Number.isFinite(input.timeoutMs) && + input.timeoutMs > 0 + ? input.timeoutMs + : OUTBOUND_ATTACHMENT_STORAGE_TIMEOUT_MS; + if ( + !Number.isSafeInteger(input.byteSize) || + input.byteSize <= 0 || + input.byteSize > MAX_ATTACHMENT_BYTES || + !/^[0-9a-f]{64}$/iu.test(input.sha256) + ) { + throw new OutboundAttachmentHydrationError( + "Chat publication attachment metadata is invalid", + ); + } + const acquisitionTimeoutError = new OutboundAttachmentHydrationError( + "Chat publication attachment storage read timed out", + ); + let acquisitionTimedOut = false; + let rejectAcquisition!: (error: Error) => void; + const acquisitionTimeout = new Promise((_resolve, reject) => { + rejectAcquisition = reject; + }); + const acquisitionTimer = setTimeout(() => { + acquisitionTimedOut = true; + rejectAcquisition(acquisitionTimeoutError); + }, timeoutMs); + acquisitionTimer.unref?.(); + const objectPromise = Promise.resolve() + .then(() => input.storage.getObject(input.companyId, input.objectKey)) + .then((object) => { + if (acquisitionTimedOut) object.stream.destroy(); + return object; + }) + .catch((error) => { + throw isOutboundAttachmentHydrationError(error) + ? error + : new OutboundAttachmentHydrationError( + "Chat publication attachment storage read failed", + error, + ); + }); + let object: Awaited>; + try { + object = await Promise.race([objectPromise, acquisitionTimeout]); + } finally { + clearTimeout(acquisitionTimer); + } + + if ( + object.contentLength !== undefined && + object.contentLength !== input.byteSize + ) { + object.stream.destroy(); + throw new OutboundAttachmentHydrationError( + "Chat publication attachment size changed after registration", + ); + } + + const streamTimeoutError = new OutboundAttachmentHydrationError( + "Chat publication attachment storage read timed out", + ); + const streamTimer = setTimeout(() => { + object.stream.destroy(streamTimeoutError); + }, timeoutMs); + streamTimer.unref?.(); + const chunks: Buffer[] = []; + const digest = createHash("sha256"); + let bytes = 0; + try { + for await (const chunk of object.stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > input.byteSize || bytes > MAX_ATTACHMENT_BYTES) { + object.stream.destroy(); + throw new OutboundAttachmentHydrationError( + "Chat publication attachment exceeded its streaming byte limit", + ); + } + digest.update(buffer); + chunks.push(buffer); + } + } catch (error) { + throw isOutboundAttachmentHydrationError(error) + ? error + : new OutboundAttachmentHydrationError( + "Chat publication attachment storage read failed", + error, + ); + } finally { + clearTimeout(streamTimer); + } + + const actualDigest = digest.digest("hex"); + if (bytes !== input.byteSize || actualDigest !== input.sha256.toLowerCase()) { + if (!object.stream.destroyed) object.stream.destroy(); + throw new OutboundAttachmentHydrationError( + "Chat publication attachment integrity changed after registration", + ); + } + + return { + data: Buffer.concat(chunks, bytes), + filename: input.filename, + mimeType: input.mimeType, + }; +} +export { isOutboundAttachmentHydrationError, isOutboundAttachmentValidationError }; + +type OutboundAttachmentsOptions = { storage?: StorageService; }; + +export function createOutboundAttachments(db: Db, options: OutboundAttachmentsOptions) { + + + + async function publicationFiles( + publication: typeof chatPublications.$inferSelect, + payload: SafeChatPublicationPayload, + preparation: { teamsInlineImagesOnly?: boolean } = {}, + ): Promise { + if (!payload.attachmentIds?.length) return []; + try { + const rows = await db + .select({ + id: issueAttachments.id, + issueId: issueAttachments.issueId, + issueCommentId: issueAttachments.issueCommentId, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + sha256: assets.sha256, + originalFilename: assets.originalFilename, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .where( + and( + eq(issueAttachments.companyId, publication.companyId), + eq(issueAttachments.issueId, publication.issueId), + inArray(issueAttachments.id, payload.attachmentIds), + ), + ); + const byId = new Map(rows.map((row) => [row.id, row])); + const orderedRows = payload.attachmentIds.map((attachmentId) => { + const row = byId.get(attachmentId); + if ( + !row || + (publication.commentId && + row.issueCommentId !== publication.commentId) || + row.byteSize <= 0 || + row.byteSize > MAX_ATTACHMENT_BYTES || + !row.objectKey.trim() || + !/^[0-9a-f]{64}$/iu.test(row.sha256) || + !isAllowedContentType(row.contentType) + ) + throw new OutboundAttachmentValidationError( + "Chat publication attachment is invalid or outside its authorized task comment", + ); + return row; + }); + // Picture messages have a different Teams contract from arbitrary files. + // Preserve the normal scope/metadata checks above, but never hydrate a + // document or oversized image just to discover it needs a task link. + if ( + preparation.teamsInlineImagesOnly && + orderedRows.some( + (row) => + row.byteSize > TEAMS_INLINE_IMAGE_MAX_BYTES || + !isTeamsInlineImageContentType(row.contentType), + ) + ) { + return []; + } + // Authorization and persisted metadata must fail definitively even when + // storage is unavailable; do not mask an invalid file as a transient outage. + if (!options.storage) + throw new OutboundAttachmentHydrationError( + "Attachment storage is unavailable for chat publication", + ); + const files: FileUpload[] = []; + for (const row of orderedRows) { + files.push( + await hydrateOutboundAttachment({ + storage: options.storage, + companyId: publication.companyId, + objectKey: row.objectKey, + byteSize: row.byteSize, + sha256: row.sha256, + filename: row.originalFilename ?? `attachment-${row.id}`, + mimeType: row.contentType, + }), + ); + } + return files; + } catch (error) { + if ( + isOutboundAttachmentHydrationError(error) || + isOutboundAttachmentValidationError(error) + ) { + throw error; + } + throw new OutboundAttachmentHydrationError( + "Chat publication attachment preparation failed", + error, + ); + } + } + return { publicationFiles }; +} diff --git a/server/src/services/chat-channels/provider-effects.ts b/server/src/services/chat-channels/provider-effects.ts new file mode 100644 index 00000000000..5ca3b4b1786 --- /dev/null +++ b/server/src/services/chat-channels/provider-effects.ts @@ -0,0 +1,957 @@ +import { createTelegramCallbackNoticeAuthority } from "./telegram/callback-notices.js"; + +import { slackThreadChannelId } from "./slack/messages.js"; + +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "./runtime-types.js"; + +import { baseTeamsConversationId } from "./microsoft-teams/resources.js"; + +import { dueChatAction, PROVIDER_EFFECT_STALE_MS } from "../chat-action-work.js"; + +import { notifyChatDeliveryWork, notifyChatActionWork } from "../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../voice/voice-runtime.js"; + +import { chatCredentialMutationLease } from "../chat-credential-mutation-lease.js"; + +import { projectSafeChatPublicationText } from "../chat-publication-projection.js"; + +import { PhotonChatAdapter } from "../photon/adapter.js"; + +import { + and, + asc, + eq, + inArray, + lte, + sql, +} from "drizzle-orm"; + +import { + teamsConversationId, +} from "../chat-control-chronology.js"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatDeliveries, + chatEndpointResources, + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatProvider, +} from "@paperclipai/shared"; + +import { + parseTelegramCallbackReceipt, + telegramCallbackThreadId, + TELEGRAM_PRIVATE_ACTION_UNAVAILABLE, + type TelegramCallbackReceipt, +} from "../chat-telegram-ephemeral.js"; + +import { logger } from "../../middleware/logger.js"; +import { redactSensitiveText } from "../../redaction.js"; + +import { classifyChatPublicationError } from "../chat-publication-errors.js"; + +import type { + Thread, +} from "chat"; + +import type { EndpointRecord } from "./runtime-types.js"; + +type ProviderEffectTarget = { + post: Thread["post"]; + postEphemeral?: Thread["postEphemeral"]; +}; +type ProviderEffectPayload = { + version: 1; + effect: "thread_message" | "ephemeral_message" | "telegram_callback_notice"; + telegramCallback?: TelegramCallbackReceipt; + telegramAuthorizationSha256?: string; + authorizationMode?: "principal" | "safe_notice"; + threadId: string; + userId?: string; + text: string; + fallbackText?: string; + settleDelivery: boolean; + completeConversationId?: string; + resourceId?: string; + runtimeGeneration: number; + credentialFingerprint: string; +}; + +function providerEffectPayload( + payload: Record, +): ProviderEffectPayload | null { + if ( + payload.version !== 1 || + (payload.effect !== "thread_message" && + payload.effect !== "ephemeral_message" && + payload.effect !== "telegram_callback_notice") || + typeof payload.threadId !== "string" || + !payload.threadId || + typeof payload.text !== "string" || + !payload.text || + typeof payload.settleDelivery !== "boolean" || + typeof payload.runtimeGeneration !== "number" || + !Number.isSafeInteger(payload.runtimeGeneration) || + typeof payload.credentialFingerprint !== "string" + ) { + return null; + } + if ( + payload.effect === "ephemeral_message" && + (typeof payload.userId !== "string" || !payload.userId) + ) { + return null; + } + for (const key of [ + "authorizationMode", + "userId", + "fallbackText", + "completeConversationId", + "resourceId", + ] as const) { + const value = payload[key]; + if (value !== undefined && (typeof value !== "string" || !value)) { + return null; + } + } + if ( + payload.authorizationMode !== undefined && + payload.authorizationMode !== "principal" && + payload.authorizationMode !== "safe_notice" + ) { + return null; + } + if (payload.effect === "telegram_callback_notice") { + const receipt = parseTelegramCallbackReceipt(payload.telegramCallback); + if ( + !receipt || + payload.authorizationMode !== "safe_notice" || + payload.text !== TELEGRAM_PRIVATE_ACTION_UNAVAILABLE || + payload.threadId !== telegramCallbackThreadId(receipt) || + payload.userId !== receipt.receiverUserId || + payload.settleDelivery !== false || + payload.fallbackText !== undefined || + payload.completeConversationId !== undefined || + typeof payload.telegramAuthorizationSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(payload.telegramAuthorizationSha256) + ) + return null; + } else if ( + payload.telegramCallback !== undefined || + payload.telegramAuthorizationSha256 !== undefined + ) + return null; + return payload as ProviderEffectPayload; +} +export type { ProviderEffectTarget, ProviderEffectPayload }; +export { providerEffectPayload }; + +type ProviderEffectsOptions = { + endpointRecord: (endpointId: string) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + withCredentialMutationLease: ReturnType; + runtimeFor: (endpoint: EndpointRow) => Promise; + invalidateRuntime: (endpointId: string) => Promise; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>; + redactError: (error: unknown) => string; + MAX_ERROR_TEXT: number; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + lockTelegramCallbackNoticeAuthority: ReturnType["lockTelegramCallbackNoticeAuthority"]; +}; + +export function createProviderEffects(db: Db, options: ProviderEffectsOptions) { + const { endpointRecord, runtimeCallbackEndpoint, withCredentialMutationLease, runtimeFor, invalidateRuntime, lockCurrentPrincipalAuthorization, redactError, MAX_ERROR_TEXT, nonDirectDestinationAllowed, lockTelegramCallbackNoticeAuthority } = options; + + + async function stageProviderEffect( + database: DbOrTransaction, + input: { + endpoint: EndpointRow; + deliveryId?: string | null; + conversationId?: string | null; + principalId?: string | null; + providerActionId: string; + payload: Omit< + ProviderEffectPayload, + "runtimeGeneration" | "credentialFingerprint" + >; + runtimeContext: LifecycleRuntimeFence; + }, + ) { + await notifyChatActionWork(database, "provider_effect"); + const [inserted] = await database + .insert(chatActions) + .values({ + companyId: input.endpoint.companyId, + endpointId: input.endpoint.id, + deliveryId: input.deliveryId ?? null, + conversationId: input.conversationId ?? null, + principalId: input.principalId ?? null, + kind: "provider_effect", + providerActionId: input.providerActionId, + payload: { + ...input.payload, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + }, + status: "received", + }) + .onConflictDoNothing() + .returning(); + if (inserted) return inserted; + return database + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.endpoint.id), + eq(chatActions.providerActionId, input.providerActionId), + eq(chatActions.kind, "provider_effect"), + ), + ) + .then((rows) => rows[0] ?? null); + } + + function providerEffectDisposition( + error: unknown, + attempt: number, + providerEffectCompleted: boolean, + ) { + if (!providerEffectCompleted) + return classifyChatPublicationError(error, attempt); + return { + kind: "delivery_unknown" as const, + reason: + "Provider effect completed, but Paperclip could not confirm its durable result", + }; + } + + async function finalizeProviderEffectFailure(input: { + action: typeof chatActions.$inferSelect; + endpoint: EndpointRow; + payload: ProviderEffectPayload; + disposition: ReturnType; + attempt: number; + }): Promise { + const failure = redactSensitiveText(input.disposition.reason).slice( + 0, + MAX_ERROR_TEXT, + ); + const retryable = input.disposition.kind === "retry"; + const retryAt = + input.disposition.kind === "retry" + ? new Date(Date.now() + input.disposition.retryAfterMs) + : null; + let invalidateCurrentRuntime = false; + const finalized = await db.transaction(async (tx) => { + // Admission locks the endpoint before its delivery. Keep that order + // when failure settlement also needs to change endpoint/resource health. + const currentEndpoint = + input.disposition.kind === "endpoint_attention" || + input.disposition.kind === "resource_unavailable" + ? await runtimeCallbackEndpoint( + tx, + input.endpoint.id, + { + generation: input.payload.runtimeGeneration, + credentialFingerprint: input.payload.credentialFingerprint, + }, + ["verifying", "active"], + ) + : null; + await notifyChatActionWork(tx, "provider_effect"); + const [ownedAction] = await tx + .update(chatActions) + .set({ + status: + input.disposition.kind === "delivery_unknown" + ? "delivery_unknown" + : "failed", + result: { + code: `provider_effect_${input.disposition.kind}`, + attempts: input.attempt, + redactedError: failure, + retryable, + retryAt: retryAt?.toISOString() ?? null, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, input.action.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${input.attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (!ownedAction) return false; + if (input.payload.settleDelivery && input.action.deliveryId) { + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: retryable ? "retry" : "failed", + nextAttemptAt: retryAt, + redactedError: + input.disposition.kind === "delivery_unknown" + ? `Provider response is unconfirmed: ${failure}`.slice( + 0, + MAX_ERROR_TEXT, + ) + : failure, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, input.action.deliveryId), + eq(chatDeliveries.state, "processing"), + ), + ); + } + if (input.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 ( + input.disposition.kind === "resource_unavailable" && + input.payload.resourceId && + currentEndpoint + ) { + await tx + .update(chatEndpointResources) + .set({ availability: "unavailable", updatedAt: new Date() }) + .where( + and( + eq(chatEndpointResources.id, input.payload.resourceId), + eq(chatEndpointResources.endpointId, currentEndpoint.id), + ), + ); + } + return true; + }); + if (finalized && invalidateCurrentRuntime) { + await invalidateRuntime(input.endpoint.id).catch(() => undefined); + } + return finalized; + } + + async function quarantineStaleProviderEffect( + action: typeof chatActions.$inferSelect, + ) { + const payload = providerEffectPayload(action.payload); + const failure = + "Provider delivery could not be confirmed after the worker stopped. The effect will not be replayed automatically."; + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "provider_effect"); + const [quarantined] = await tx + .update(chatActions) + .set({ + status: "delivery_unknown", + result: { + code: "provider_effect_delivery_unknown", + redactedError: failure, + retryable: false, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + eq(chatActions.updatedAt, action.updatedAt), + sql`(${chatActions.result}->>'attempts')::int = ${ + typeof action.result?.attempts === "number" + ? action.result.attempts + : -1 + }`, + lte( + chatActions.updatedAt, + new Date(Date.now() - PROVIDER_EFFECT_STALE_MS), + ), + ), + ) + .returning({ id: chatActions.id }); + if (quarantined && payload?.settleDelivery && action.deliveryId) { + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "failed", + nextAttemptAt: null, + redactedError: failure, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.state, "processing"), + ), + ); + } + }); + } + + function providerEffectThreadResourceId( + provider: ChatProvider, + threadId: string, + ): string | null { + if (provider === "slack") { + return ( + slackThreadChannelId(threadId) ?? + (!threadId.includes(":") ? threadId : null) + ); + } + if (provider === "telegram") { + return /^telegram:([^:]+)/.exec(threadId)?.[1] ?? null; + } + if (provider === "github") { + return /^github:([^:]+)/.exec(threadId)?.[1]?.toLowerCase() ?? null; + } + if (provider === "discord") { + return /^discord:[^:]+:([^:]+)/.exec(threadId)?.[1] ?? null; + } + const conversationId = teamsConversationId(threadId); + return conversationId ? baseTeamsConversationId(conversationId) : null; + } + + async function lockCurrentProviderEffectAuthorization( + tx: DbTransaction, + action: typeof chatActions.$inferSelect, + endpoint: EndpointRow, + payload: ProviderEffectPayload, + ): Promise { + if (payload.effect === "telegram_callback_notice") { + const receipt = parseTelegramCallbackReceipt(payload.telegramCallback); + if (!receipt || !action.deliveryId) return false; + const delivery = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.companyId, action.companyId), + eq(chatDeliveries.endpointId, action.endpointId), + eq(chatDeliveries.state, "filtered"), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const retained = + delivery && + parseTelegramCallbackReceipt(delivery.normalizedEvent.telegramCallback); + if ( + !delivery || + delivery.principalId !== action.principalId || + retained?.sourceSha256 !== receipt.sourceSha256 || + delivery.normalizedEvent.telegramAuthorizationSha256 !== + payload.telegramAuthorizationSha256 + ) + return false; + return ( + (await lockTelegramCallbackNoticeAuthority( + tx, + endpoint, + receipt, + action.principalId, + )) === payload.telegramAuthorizationSha256 + ); + } + const conversation = action.conversationId + ? await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, action.conversationId), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + if (action.conversationId && !conversation) return false; + const inferredResourceId = providerEffectThreadResourceId( + endpoint.provider, + payload.threadId, + ); + const resource = await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + payload.resourceId || conversation?.resourceId + ? eq( + chatEndpointResources.id, + payload.resourceId ?? conversation!.resourceId!, + ) + : inferredResourceId + ? eq(chatEndpointResources.providerResourceId, inferredResourceId) + : sql`false`, + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const slackChannelId = + endpoint.provider === "slack" + ? slackThreadChannelId(payload.threadId) + : null; + const isDirectMessage = + conversation?.isDirectMessage === true || + resource?.type === "direct_message" || + Boolean(slackChannelId && /^D[A-Z0-9]+$/i.test(slackChannelId)); + const destinationAllowed = isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource); + if (payload.authorizationMode === "safe_notice") return true; + if (!destinationAllowed) return false; + if (!action.principalId) return false; + return ( + await lockCurrentPrincipalAuthorization(tx, endpoint, action.principalId) + ).allowed; + } + + async function cancelUnauthorizedProviderEffect( + tx: DbTransaction, + action: typeof chatActions.$inferSelect, + attempt: number, + ): Promise { + await notifyChatActionWork(tx, "provider_effect"); + const [cancelled] = await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + attempts: attempt, + code: "provider_effect_no_longer_authorized", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (!cancelled) return false; + if (action.deliveryId) { + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "filtered", + processedAt: new Date(), + nextAttemptAt: null, + redactedError: + "Provider reply was suppressed because current chat authorization changed", + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.state, "processing"), + ), + ); + } + return true; + } + + async function processProviderEffect( + actionId: string, + liveTarget?: ProviderEffectTarget, + ): Promise<"processed" | "pending" | "failed" | "delivery_unknown"> { + const initial = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.id, actionId), + eq(chatActions.kind, "provider_effect"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!initial) return "failed"; + const initialEndpoint = await endpointRecord(initial.endpointId); + if (!initialEndpoint) return "failed"; + + const processClaimedEffect = async () => { + let action = await db + .select() + .from(chatActions) + .where(eq(chatActions.id, actionId)) + .then((rows) => rows[0] ?? null); + if (!action) return "failed"; + const payload = providerEffectPayload(action.payload); + if (!payload) { + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "provider_effect"); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { code: "provider_effect_payload_invalid" }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, action.id)); + }); + return "failed"; + } + if (action.status === "processed") return "processed"; + if (action.status === "delivery_unknown") return "delivery_unknown"; + if (action.status === "processing") { + if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS)) + return "pending"; + await quarantineStaleProviderEffect(action); + return "delivery_unknown"; + } + if (action.status === "failed") { + if (action.result?.retryable !== true) return "failed"; + const retryAt = + typeof action.result.retryAt === "string" + ? new Date(action.result.retryAt) + : null; + if (retryAt && retryAt > new Date()) return "pending"; + } else if (action.status !== "received") { + return "failed"; + } + + const attempt = + (typeof action.result?.attempts === "number" + ? action.result.attempts + : 0) + 1; + const record = await endpointRecord(action.endpointId); + let providerEffectCompleted = false; + try { + if (!record) { + throw Object.assign( + new Error("Chat endpoint is unavailable for this provider effect"), + { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, + ); + } + return await withCredentialMutationLease( + record.endpoint, + async (credentialLease) => { + const claim = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const currentAction = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.kind, "provider_effect"), + eq(chatActions.status, action!.status), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!currentAction) return null; + const currentEndpoint = await runtimeCallbackEndpoint( + tx, + currentAction.endpointId, + { + generation: payload.runtimeGeneration, + credentialFingerprint: payload.credentialFingerprint, + }, + ["verifying", "active"], + ); + const authorized = currentEndpoint + ? await lockCurrentProviderEffectAuthorization( + tx, + currentAction, + currentEndpoint, + payload, + ) + : false; + await notifyChatActionWork(tx, "provider_effect"); + const [transportClaim] = await tx + .update(chatActions) + .set({ + status: "processing", + result: { attempts: attempt }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, currentAction.id), + eq(chatActions.status, currentAction.status), + ), + ) + .returning(); + if (!transportClaim) return null; + if (!currentEndpoint || !authorized) { + const cancelled = await cancelUnauthorizedProviderEffect( + tx, + transportClaim, + attempt, + ); + if (!cancelled) { + throw new Error( + "Provider effect ownership changed before authorization cancellation", + ); + } + return { outcome: "cancelled" as const }; + } + await credentialLease.assertOwned(tx); + return { + action: transportClaim, + endpoint: currentEndpoint, + outcome: "claimed" as const, + }; + }); + if (!claim) return "pending"; + if (claim.outcome === "cancelled") return "failed"; + action = claim.action; + + // `processing` is the durable, single-owner transport claim and + // authorization linearization point. Its transaction has committed + // before provider I/O begins. The renewable credential lease keeps + // runtime identity stable without holding database row locks. + let sent: { id: string; threadId: string } | null = null; + if (payload.effect === "telegram_callback_notice") { + sent = await ( + await runtimeFor(claim.endpoint) + ).sendTelegramCallbackNotice( + payload.telegramCallback!, + payload.text, + ); + } else { + const target = + liveTarget ?? + (await runtimeFor(claim.endpoint)).thread(payload.threadId); + if (payload.effect === "ephemeral_message") { + if (!target.postEphemeral) { + if (!payload.fallbackText) { + throw Object.assign( + new Error("Provider does not support ephemeral messages"), + { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, + ); + } + sent = await target.post(payload.fallbackText); + } else { + try { + sent = await target.postEphemeral( + payload.userId!, + payload.text, + { fallbackToDM: false }, + ); + } catch (error) { + if ( + !payload.fallbackText || + classifyChatPublicationError(error, attempt).kind !== + "failed" + ) { + throw error; + } + } + if (!sent && payload.fallbackText) { + sent = await target.post(payload.fallbackText); + } + } + } else if (claim.endpoint.provider === "imessage-photon") { + const adapter = (await runtimeFor(claim.endpoint)).getProviderAdapter(); + if (!(adapter instanceof PhotonChatAdapter)) throw new Error("Photon runtime unavailable"); + const receipt = await adapter.publish(payload.threadId, `effect:${action.id}`, projectSafeChatPublicationText(payload.text), { + assertCurrent: async () => { await credentialLease.assertOwned(); }, + }); + sent = { id: receipt.id, threadId: payload.threadId }; + } else { + sent = await target.post(payload.text); + } + } + if (!sent) { + throw Object.assign( + new Error("Provider did not accept the message"), + { code: "CHAT_PROVIDER_PRETRANSPORT_REJECTED" }, + ); + } + providerEffectCompleted = true; + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + // A duplicate webhook holds the endpoint while waiting for the + // delivery's unique-index entry. Lock this endpoint first too, + // before settling the action/conversation/delivery, so neither + // transaction can wait on a row already held by the other. + const currentEndpoint = await runtimeCallbackEndpoint( + tx, + claim.endpoint.id, + { + generation: payload.runtimeGeneration, + credentialFingerprint: payload.credentialFingerprint, + }, + ["verifying", "active"], + ); + await notifyChatActionWork(tx, "provider_effect"); + const [completed] = await tx + .update(chatActions) + .set({ + status: "processed", + result: { + attempts: attempt, + providerMessageId: sent.id, + threadId: sent.threadId, + ...(payload.effect === "telegram_callback_notice" + ? { + code: + payload.telegramCallback?.chatType === "private" + ? "telegram_private_api_accepted" + : "telegram_ephemeral_api_accepted", + } + : {}), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (!completed) { + throw new Error( + "Provider effect ownership changed before commit", + ); + } + if (payload.completeConversationId) { + await tx + .update(chatConversations) + .set({ state: "completed", updatedAt: new Date() }) + .where( + and( + eq(chatConversations.id, payload.completeConversationId), + eq(chatConversations.endpointId, claim.endpoint.id), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ); + } + if (payload.settleDelivery && action!.deliveryId) { + await notifyChatDeliveryWork(tx); + const [settled] = await tx + .update(chatDeliveries) + .set({ + state: "processed", + processedAt: new Date(), + nextAttemptAt: null, + redactedError: null, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, action!.deliveryId), + eq(chatDeliveries.state, "processing"), + ), + ) + .returning({ id: chatDeliveries.id }); + if (!settled) { + throw new Error( + "Provider effect delivery ownership changed before commit", + ); + } + } + if (currentEndpoint) { + await tx + .update(chatEndpoints) + .set({ lastEventAt: new Date(), updatedAt: new Date() }) + .where(eq(chatEndpoints.id, currentEndpoint.id)); + } + await credentialLease.assertOwned(tx); + }); + return "processed"; + }, + ); + } catch (error) { + const disposition = providerEffectDisposition( + error, + attempt, + providerEffectCompleted, + ); + try { + await finalizeProviderEffectFailure({ + action, + endpoint: record?.endpoint ?? initialEndpoint.endpoint, + payload, + disposition, + attempt, + }); + } catch (finalizationError) { + if (disposition.kind === "delivery_unknown") { + throw Object.assign( + new Error( + "Provider effect is ambiguous and its durable quarantine failed", + { cause: finalizationError }, + ), + { code: "CHAT_PROVIDER_EFFECT_AMBIGUOUS" }, + ); + } + throw finalizationError; + } + throw error; + } + }; + return processClaimedEffect(); + } + + async function processPendingProviderEffects(limit = 25) { + const now = new Date(); + const staleBefore = new Date(now.getTime() - PROVIDER_EFFECT_STALE_MS); + const actions = await db + .select() + .from(chatActions) + .where(dueChatAction("provider_effect", now.getTime())) + .orderBy(asc(chatActions.createdAt)) + .limit(limit); + for (const action of actions) { + if (action.status === "processing" && action.updatedAt <= staleBefore) { + await quarantineStaleProviderEffect(action); + continue; + } + try { + await processProviderEffect(action.id); + } catch (error) { + logger.warn( + { + endpointId: action.endpointId, + actionId: action.id, + error: redactError(error), + }, + "chat provider effect reconciliation failed", + ); + } + } + return actions.length; + } + return { stageProviderEffect, processProviderEffect, processPendingProviderEffects }; +} diff --git a/server/src/services/chat-channels/reactions.ts b/server/src/services/chat-channels/reactions.ts new file mode 100644 index 00000000000..83df24432e9 --- /dev/null +++ b/server/src/services/chat-channels/reactions.ts @@ -0,0 +1,891 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "./runtime-types.js"; + +import { + stableExternalPrincipalId, +} from "./message-lifecycle.js"; + +import { notifyChatDeliveryWork } from "../chat-work-notifications.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + asc, + desc, + eq, + gt, + gte, + inArray, + isNull, + lt, + lte, + or, + sql, + type SQL, +} from "drizzle-orm"; + +import { + externalThreadIdentityCondition, +} from "../chat-control-chronology.js"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatDeliveries, + chatEndpointResources, + chatMessageLinks, + chatPublications, +} from "@paperclipai/db"; +import type { + ChatEventKind, + ChatProvider, +} from "@paperclipai/shared"; + +import { logger } from "../../middleware/logger.js"; + +import { + type ChatSdkCallbackEvent, +} from "../chat-sdk-runtime.js"; + +import type { + Author, + ReactionEvent, +} from "chat"; + +import type { EndpointRecord } from "./runtime-types.js"; +import { slackThreadChannelId } from "./slack/messages.js"; + +// Reactions can beat the transaction that persists a just-sent provider +// message link. Keep that narrow gap durable, but never retain an unbound +// reaction indefinitely or let it enter the ordinary inbound-message FIFO. +const REACTION_LINK_RETRY_DELAY_MS = 250; +const REACTION_LINK_MAX_DELAY_MS = 5_000; +const REACTION_LINK_MAX_ATTEMPTS = 20; +const REACTION_LINK_MAX_AGE_MS = 2 * 60_000; +type DeliveryRow = typeof chatDeliveries.$inferSelect; +type ConversationRow = typeof chatConversations.$inferSelect; + +type InboundReactionsOptions = { + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>; + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise<{ principal: { id: string } }>; + durableExternalThreadIdentity: (threadId: string) => string; + recordCurrentMicrosoftTeamsRoute: (endpoint: EndpointRow, context: InboundRuntimeContext | undefined, threadId: string, raw: unknown) => Promise; + lifecycleRuntimeFence: (delivery: DeliveryRow) => LifecycleRuntimeFence | null; + deliveryReady: (delivery: DeliveryRow, now: Date) => boolean; + redactError: (error: unknown) => string; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + reactionLinkPreflightBarrier?: () => Promise; + reactionReplayEndpointLockBarrier?: () => Promise; + reactionReplayConversationLockBarrier?: () => Promise; +}; + +export function createInboundReactions(db: Db, options: InboundReactionsOptions) { + const { lockCurrentPrincipalAuthorization, runtimeCallbackRecord, runtimeCallbackEndpoint, ensurePrincipal, durableExternalThreadIdentity, recordCurrentMicrosoftTeamsRoute, lifecycleRuntimeFence, deliveryReady, redactError, nonDirectDestinationAllowed } = options; + + + function reactionConversationThreadCondition( + provider: ChatProvider, + threadId: string, + ): SQL | undefined { + const exact = externalThreadIdentityCondition( + sql`${chatConversations.externalThreadId}`, + threadId, + ); + const slackDmChannel = + provider === "slack" ? slackThreadChannelId(threadId) : null; + return slackDmChannel && /^D[A-Z0-9-]*$/i.test(slackDmChannel) + ? or( + exact, + and( + eq(chatConversations.isDirectMessage, true), + eq(chatConversations.externalThreadId, `slack:${slackDmChannel}:`), + ), + ) + : exact; + } + + async function reactionConversationForMessage( + database: DbOrTransaction, + endpoint: EndpointRow, + threadId: string, + providerMessageId: string, + direction?: "outbound", + ): Promise { + return database + .select({ conversation: chatConversations }) + .from(chatMessageLinks) + .innerJoin( + chatConversations, + and( + eq(chatConversations.companyId, chatMessageLinks.companyId), + eq(chatConversations.endpointId, chatMessageLinks.endpointId), + eq(chatConversations.id, chatMessageLinks.conversationId), + ), + ) + .where( + and( + eq(chatMessageLinks.companyId, endpoint.companyId), + eq(chatMessageLinks.endpointId, endpoint.id), + eq(chatMessageLinks.providerMessageId, providerMessageId), + direction ? eq(chatMessageLinks.direction, direction) : undefined, + reactionConversationThreadCondition(endpoint.provider, threadId), + ), + ) + .orderBy(desc(chatConversations.sessionGeneration)) + .limit(1) + .then((rows) => rows[0]?.conversation ?? null); + } + + async function lockReactionConversationForMessage( + tx: DbTransaction, + endpoint: EndpointRow, + threadId: string, + providerMessageId: string, + direction?: "outbound", + ): Promise { + const candidate = await reactionConversationForMessage( + tx, + endpoint, + threadId, + providerMessageId, + direction, + ); + if (!candidate) return null; + const current = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, candidate.id), + ), + ) + // Publication settlement inserts the outbound message link before it + // updates the endpoint. NO KEY UPDATE serializes state changes while + // remaining compatible with that link's conversation FK KEY SHARE. + .for("no key update") + .then((rows) => rows[0] ?? null); + if ( + !current || + !(current.isDirectMessage + ? ["active", "waiting", "completed"].includes(current.state) + : ["active", "waiting"].includes(current.state)) + ) { + return null; + } + const linkStillExists = await tx + .select({ id: chatMessageLinks.id }) + .from(chatMessageLinks) + .where( + and( + eq(chatMessageLinks.companyId, endpoint.companyId), + eq(chatMessageLinks.endpointId, endpoint.id), + eq(chatMessageLinks.conversationId, current.id), + eq(chatMessageLinks.providerMessageId, providerMessageId), + direction ? eq(chatMessageLinks.direction, direction) : undefined, + ), + ) + .limit(1) + .then((rows) => rows.length > 0); + return linkStillExists ? current : null; + } + + async function streamingReactionConversation( + database: DbOrTransaction, + endpoint: EndpointRow, + threadId: string, + ): Promise { + const candidates = await database + .selectDistinct({ + conversation: chatConversations, + sessionGeneration: chatConversations.sessionGeneration, + }) + .from(chatConversations) + .innerJoin( + chatPublications, + and( + eq(chatPublications.companyId, chatConversations.companyId), + eq(chatPublications.endpointId, chatConversations.endpointId), + eq(chatPublications.conversationId, chatConversations.id), + ), + ) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + or( + inArray(chatConversations.state, ["active", "waiting"]), + and( + eq(chatConversations.isDirectMessage, true), + eq(chatConversations.state, "completed"), + ), + ), + reactionConversationThreadCondition(endpoint.provider, threadId), + eq(chatPublications.state, "streaming"), + ), + ) + .orderBy(desc(chatConversations.sessionGeneration)) + .limit(2); + if (candidates.length !== 1) return null; + return candidates[0]!.conversation; + } + + async function lockStreamingReactionConversation( + tx: DbTransaction, + endpoint: EndpointRow, + threadId: string, + ): Promise { + const candidate = await streamingReactionConversation( + tx, + endpoint, + threadId, + ); + if (!candidate) return null; + const current = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, candidate.id), + or( + inArray(chatConversations.state, ["active", "waiting"]), + and( + eq(chatConversations.isDirectMessage, true), + eq(chatConversations.state, "completed"), + ), + ), + ), + ) + // Keep the same lock strength/order as the exact-link path above so an + // in-flight message-link insert cannot deadlock reaction admission. + .for("no key update") + .then((rows) => rows[0] ?? null); + if (!current) return null; + const stillStreaming = await tx + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.conversationId, current.id), + eq(chatPublications.state, "streaming"), + ), + ) + .limit(1) + .then((rows) => rows.length > 0); + return stillStreaming ? current : null; + } + + async function authorizeReactionConversation( + tx: DbTransaction, + endpoint: EndpointRow, + conversation: ConversationRow, + principalId: string, + ): Promise { + const resource = + 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 destinationAllowed = conversation.isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource); + if (!destinationAllowed) return false; + return (await lockCurrentPrincipalAuthorization(tx, endpoint, principalId)) + .allowed; + } + + async function handleReaction( + event: ChatSdkCallbackEvent, + runtimeContext: RuntimeContext, + ) { + const record = await runtimeCallbackRecord( + event.endpointId, + runtimeContext, + ["active"], + ); + if ( + !record || + record.endpoint.status !== "active" || + record.endpoint.provider !== event.provider || + record.endpoint.capabilities.reactions !== true || + event.event.user.isMe || + event.event.user.isBot === true || + event.event.user.isSystem + ) { + return; + } + // A DM can have a newer task generation while this reaction still belongs + // to an older linked message. Resolve its exact lineage before choosing a + // generation; never attach it to the latest task merely sharing the chat. + // A just-sent provider message can be visible before its publication/link + // transaction commits. Only that exact thread's streaming publication is + // enough evidence to stage an otherwise-unbound reaction for replay. + let conversation = await reactionConversationForMessage( + db, + record.endpoint, + event.event.threadId, + event.event.messageId, + ); + await options.reactionLinkPreflightBarrier?.(); + const plausibleStreamingConversation = conversation + ? null + : await streamingReactionConversation( + db, + record.endpoint, + event.event.threadId, + ); + if (!conversation && !plausibleStreamingConversation) { + // Settlement can commit between the first exact-link lookup and the + // streaming-publication lookup. Recheck the durable link before treating + // the callback as an arbitrary unknown message. + conversation = await reactionConversationForMessage( + db, + record.endpoint, + event.event.threadId, + event.event.messageId, + ); + if (!conversation) return; + } + + const principal = await ensurePrincipal( + record.endpoint, + event.event.user, + event.event.raw, + ); + let rawFingerprint: string; + try { + rawFingerprint = createHash("sha256") + .update(JSON.stringify(event.event.raw ?? null)) + .digest("hex") + .slice(0, 24); + } catch { + rawFingerprint = "unavailable"; + } + const eventKind: ChatEventKind = event.event.added + ? "reaction_added" + : "reaction_removed"; + const providerEventId = [ + eventKind, + durableExternalThreadIdentity(event.event.threadId), + event.event.messageId, + stableExternalPrincipalId( + record.endpoint.provider, + event.event.user, + event.event.raw, + ), + event.event.rawEmoji, + rawFingerprint, + ].join(":"); + const normalizedEvent = { + providerEventId, + kind: eventKind, + conversation: { externalThreadId: event.event.threadId }, + message: { providerMessageId: event.event.messageId }, + reaction: { + emoji: event.event.emoji.name, + rawEmoji: event.event.rawEmoji, + added: event.event.added, + }, + runtimeContext: { + generation: runtimeContext.generation, + credentialFingerprint: runtimeContext.credentialFingerprint, + }, + }; + const admitted = await db.transaction(async (tx) => { + const currentEndpoint = await runtimeCallbackEndpoint( + tx, + event.endpointId, + runtimeContext, + ["active"], + ); + if (!currentEndpoint) return false; + const linkedConversation = await lockReactionConversationForMessage( + tx, + currentEndpoint, + event.event.threadId, + event.event.messageId, + ); + const currentConversation = + linkedConversation ?? + (await lockStreamingReactionConversation( + tx, + currentEndpoint, + event.event.threadId, + )); + if ( + !currentConversation || + !(await authorizeReactionConversation( + tx, + currentEndpoint, + currentConversation, + principal.principal.id, + )) + ) { + return false; + } + const now = new Date(); + if (linkedConversation) { + const expiryCutoff = new Date(now.getTime() - REACTION_LINK_MAX_AGE_MS); + const hasOutboundLink = await tx + .select({ id: chatMessageLinks.id }) + .from(chatMessageLinks) + .where( + and( + eq(chatMessageLinks.companyId, currentEndpoint.companyId), + eq(chatMessageLinks.endpointId, currentEndpoint.id), + eq(chatMessageLinks.conversationId, linkedConversation.id), + eq(chatMessageLinks.providerMessageId, event.event.messageId), + eq(chatMessageLinks.direction, "outbound"), + ), + ) + .limit(1) + .then((rows) => rows.length > 0); + await notifyChatDeliveryWork(tx); + const reconciled = hasOutboundLink + ? await tx + .update(chatDeliveries) + .set({ + conversationId: linkedConversation.id, + state: "processed", + attempts: sql`${chatDeliveries.attempts} + 1`, + nextAttemptAt: null, + processedAt: now, + redactedError: null, + updatedAt: now, + }) + .where( + and( + eq(chatDeliveries.endpointId, currentEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + inArray(chatDeliveries.state, ["received", "retry"]), + gt(chatDeliveries.receivedAt, expiryCutoff), + lt(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS), + sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`, + sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`, + ), + ) + .returning({ id: chatDeliveries.id }) + : []; + if (reconciled.length > 0) return true; + await notifyChatDeliveryWork(tx); + const expired = await tx + .update(chatDeliveries) + .set({ + state: "filtered", + nextAttemptAt: null, + processedAt: now, + redactedError: + "Reaction target did not become linked before replay expiry", + updatedAt: now, + }) + .where( + and( + eq(chatDeliveries.endpointId, currentEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + inArray(chatDeliveries.state, ["received", "retry"]), + or( + lte(chatDeliveries.receivedAt, expiryCutoff), + gte(chatDeliveries.attempts, REACTION_LINK_MAX_ATTEMPTS), + ), + sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'generation' = ${String(runtimeContext.generation)}`, + sql`${chatDeliveries.normalizedEvent}->'runtimeContext'->>'credentialFingerprint' = ${runtimeContext.credentialFingerprint}`, + ), + ) + .returning({ id: chatDeliveries.id }); + if (expired.length > 0) return false; + } + await notifyChatDeliveryWork(tx); + const inserted = await tx + .insert(chatDeliveries) + .values({ + companyId: currentEndpoint.companyId, + endpointId: currentEndpoint.id, + conversationId: linkedConversation?.id ?? null, + principalId: principal.principal.id, + providerEventId, + deduplicationKey: createHash("sha256") + .update(providerEventId) + .digest("hex"), + eventKind, + normalizedEvent, + state: linkedConversation ? "processed" : "received", + attempts: linkedConversation ? 1 : 0, + nextAttemptAt: linkedConversation + ? null + : new Date(now.getTime() + REACTION_LINK_RETRY_DELAY_MS), + processedAt: linkedConversation ? now : null, + }) + .onConflictDoNothing() + .returning({ id: chatDeliveries.id }); + if (inserted.length > 0) return true; + return tx + .select({ id: chatDeliveries.id }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.endpointId, currentEndpoint.id), + eq(chatDeliveries.providerEventId, providerEventId), + inArray(chatDeliveries.state, ["received", "retry", "processed"]), + ), + ) + .limit(1) + .then((rows) => rows.length > 0); + }); + if (admitted) { + await recordCurrentMicrosoftTeamsRoute( + record.endpoint, + runtimeContext, + event.event.threadId, + event.event.raw, + ); + } + } + + function normalizedPendingReaction(delivery: DeliveryRow): { + added: boolean; + emoji: string; + messageId: string; + rawEmoji: string; + runtimeContext: LifecycleRuntimeFence; + threadId: string; + } | null { + if ( + delivery.eventKind !== "reaction_added" && + delivery.eventKind !== "reaction_removed" + ) { + return null; + } + const normalized = delivery.normalizedEvent; + if ( + !normalized || + typeof normalized !== "object" || + Array.isArray(normalized) + ) { + return null; + } + const value = normalized as Record; + const conversation = value.conversation; + const message = value.message; + const reaction = value.reaction; + const runtimeContext = lifecycleRuntimeFence(delivery); + if ( + !conversation || + typeof conversation !== "object" || + Array.isArray(conversation) || + !message || + typeof message !== "object" || + Array.isArray(message) || + !reaction || + typeof reaction !== "object" || + Array.isArray(reaction) || + !runtimeContext + ) { + return null; + } + const threadId = (conversation as Record).externalThreadId; + const messageId = (message as Record).providerMessageId; + const reactionRecord = reaction as Record; + const added = reactionRecord.added; + const emoji = reactionRecord.emoji; + const rawEmoji = reactionRecord.rawEmoji; + return typeof threadId === "string" && + threadId.length > 0 && + typeof messageId === "string" && + messageId.length > 0 && + typeof added === "boolean" && + added === (delivery.eventKind === "reaction_added") && + typeof emoji === "string" && + emoji.length > 0 && + typeof rawEmoji === "string" && + rawEmoji.length > 0 + ? { added, emoji, messageId, rawEmoji, runtimeContext, threadId } + : null; + } + + async function processPendingReactionDelivery( + candidate: DeliveryRow, + ): Promise { + const reaction = normalizedPendingReaction(candidate); + if (!reaction || !candidate.principalId) { + const now = new Date(); + await db.transaction(async (tx) => { + await notifyChatDeliveryWork(tx); + return tx + .update(chatDeliveries) + .set({ + state: "failed", + nextAttemptAt: null, + processedAt: now, + redactedError: "Normalized reaction delivery is incomplete", + updatedAt: now, + }) + .where( + and( + eq(chatDeliveries.id, candidate.id), + inArray(chatDeliveries.state, ["received", "retry"]), + ), + ); + }); + return; + } + const principalId = candidate.principalId; + + await db.transaction(async (tx) => { + const currentEndpoint = await runtimeCallbackEndpoint( + tx, + candidate.endpointId, + reaction.runtimeContext, + ["active"], + ); + let conversation: ConversationRow | null = null; + let disposition: + | { kind: "processed"; conversationId: string } + | { kind: "retry" } + | { kind: "filtered"; reason: string }; + + if (!currentEndpoint) { + disposition = { + kind: "filtered", + reason: "Reaction callback belonged to an unavailable runtime", + }; + } else { + await options.reactionReplayEndpointLockBarrier?.(); + conversation = await lockReactionConversationForMessage( + tx, + currentEndpoint, + reaction.threadId, + reaction.messageId, + "outbound", + ); + if (conversation) { + await options.reactionReplayConversationLockBarrier?.(); + } + if (conversation) { + disposition = (await authorizeReactionConversation( + tx, + currentEndpoint, + conversation, + principalId, + )) + ? { kind: "processed", conversationId: conversation.id } + : { + kind: "filtered", + reason: + "Reaction destination or principal is no longer authorized", + }; + } else { + conversation = await lockStreamingReactionConversation( + tx, + currentEndpoint, + reaction.threadId, + ); + if (conversation) { + await options.reactionReplayConversationLockBarrier?.(); + } + if (!conversation) { + disposition = { + kind: "filtered", + reason: "Reaction target was not linked to a sent message", + }; + } else { + disposition = (await authorizeReactionConversation( + tx, + currentEndpoint, + conversation, + principalId, + )) + ? { kind: "retry" } + : { + kind: "filtered", + reason: + "Reaction destination or principal is no longer authorized", + }; + } + } + } + + // Endpoint, conversation, destination and identity locks are acquired + // before the delivery row so pause/revoke and replay use one lock order. + const locked = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.id, candidate.id), + inArray(chatDeliveries.state, ["received", "retry"]), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + // Authorization locks may wait behind a concurrent lifecycle mutation. + // Sample the expiry clock only after this delivery is exclusively held. + const decisionAt = new Date(); + if (!locked || !deliveryReady(locked, decisionAt)) return; + const lockedReaction = normalizedPendingReaction(locked); + if ( + !lockedReaction || + lockedReaction.runtimeContext.generation !== + reaction.runtimeContext.generation || + lockedReaction.runtimeContext.credentialFingerprint !== + reaction.runtimeContext.credentialFingerprint + ) { + disposition = { + kind: "filtered", + reason: "Reaction delivery identity changed before replay", + }; + } + + const nextAttempts = locked.attempts + 1; + const expired = + decisionAt.getTime() - locked.receivedAt.getTime() >= + REACTION_LINK_MAX_AGE_MS; + if (expired || locked.attempts >= REACTION_LINK_MAX_ATTEMPTS) { + disposition = { + kind: "filtered", + reason: "Reaction target did not become linked before replay expiry", + }; + } + if (disposition.kind === "retry" && !expired) { + if (nextAttempts < REACTION_LINK_MAX_ATTEMPTS) { + const delay = Math.min( + REACTION_LINK_RETRY_DELAY_MS * + 2 ** Math.min(Math.max(locked.attempts, 0), 5), + REACTION_LINK_MAX_DELAY_MS, + ); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "retry", + attempts: nextAttempts, + nextAttemptAt: new Date(decisionAt.getTime() + delay), + redactedError: "Waiting for the sent message link", + updatedAt: decisionAt, + }) + .where( + and( + eq(chatDeliveries.id, locked.id), + eq(chatDeliveries.state, locked.state), + eq(chatDeliveries.attempts, locked.attempts), + ), + ); + return; + } + disposition = { + kind: "filtered", + reason: "Reaction target did not become linked before retry expiry", + }; + } else if (disposition.kind === "retry") { + disposition = { + kind: "filtered", + reason: "Reaction target did not become linked before age expiry", + }; + } + + const processedAt = new Date(); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set( + disposition.kind === "processed" + ? { + conversationId: disposition.conversationId, + state: "processed", + attempts: nextAttempts, + nextAttemptAt: null, + processedAt, + redactedError: null, + updatedAt: processedAt, + } + : { + state: "filtered", + attempts: nextAttempts, + nextAttemptAt: null, + processedAt, + redactedError: disposition.reason, + updatedAt: processedAt, + }, + ) + .where( + and( + eq(chatDeliveries.id, locked.id), + eq(chatDeliveries.state, locked.state), + eq(chatDeliveries.attempts, locked.attempts), + ), + ); + }); + } + + async function processPendingReactionDeliveries( + limit: number, + onlyDeliveryId?: string, + ): Promise { + const now = new Date(); + const candidates = await db + .select() + .from(chatDeliveries) + .where( + and( + sql`not exists (select 1 from chat_endpoints e where e.id = ${chatDeliveries.endpointId} and e.provider = 'agentmail')`, + onlyDeliveryId ? eq(chatDeliveries.id, onlyDeliveryId) : undefined, + inArray(chatDeliveries.eventKind, [ + "reaction_added", + "reaction_removed", + ]), + or( + and( + eq(chatDeliveries.state, "received"), + or( + isNull(chatDeliveries.nextAttemptAt), + lte(chatDeliveries.nextAttemptAt, now), + ), + ), + and( + eq(chatDeliveries.state, "retry"), + or( + isNull(chatDeliveries.nextAttemptAt), + lte(chatDeliveries.nextAttemptAt, now), + ), + ), + ), + ), + ) + .orderBy(asc(chatDeliveries.receivedAt), asc(chatDeliveries.id)) + .limit(limit); + for (const candidate of candidates) { + try { + await processPendingReactionDelivery(candidate); + } catch (error) { + logger.warn( + { + endpointId: candidate.endpointId, + deliveryId: candidate.id, + error: redactError(error), + }, + "chat reaction-link reconciliation failed", + ); + } + } + return candidates.length; + } + return { handleReaction, processPendingReactionDeliveries }; +} diff --git a/server/src/services/chat-channels/receipt-reactions.ts b/server/src/services/chat-channels/receipt-reactions.ts new file mode 100644 index 00000000000..a733091a127 --- /dev/null +++ b/server/src/services/chat-channels/receipt-reactions.ts @@ -0,0 +1,586 @@ +import type { ChatRuntime } from "../chat-runtime.js"; +import type { EndpointRow, RuntimeContext, LifecycleRuntimeFence } from "./runtime-types.js"; + +import { dueChatAction, PROVIDER_EFFECT_STALE_MS } from "../chat-action-work.js"; + +import { notifyChatActionWork } from "../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../voice/voice-runtime.js"; + +import { + receiptReactionPayload, + type ReceiptReactionPayload, +} from "../chat-receipt-reactions.js"; + +import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../chat-credential-mutation-lease.js"; + +import { + and, + asc, + eq, + like, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatDeliveries, + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, + ChatProvider, + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import { + parseGitHubReceiptIdentity, + type GitHubReceiptIdentity, +} from "../chat-github-receipt-reactions.js"; + +import { logger } from "../../middleware/logger.js"; + +import { classifyChatPublicationError } from "../chat-publication-errors.js"; + +import type { + Message, + Thread, +} from "chat"; + +import type { EndpointRecord } from "./runtime-types.js"; +import { isSlackReceiptReactionAlreadyApplied } from "./slack/messages.js"; + +type ReceiptReactionsOptions = { + endpointRecord: (endpointId: string) => Promise; + runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext; + withCredentialMutationLease: ReturnType; + runtimeFor: (endpoint: EndpointRow) => Promise; + fetchImpl: typeof fetch; + runtime: Pick; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + redactError: (error: unknown) => string; + MAX_ERROR_TEXT: number; + receiptReactionTransportBarrier?: () => Promise; +}; + +export function createReceiptReactions(db: Db, options: ReceiptReactionsOptions) { + const { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, runtimeFor, fetchImpl, runtime, runtimeGeneration, credentialFingerprint, redactError, MAX_ERROR_TEXT } = options; + + + async function addReceiptReaction(input: { + deliveryId: string; + endpoint: EndpointRow; + message: Message; + runtimeContext?: LifecycleRuntimeFence; + thread: Thread; + }): Promise { + // GitHub acknowledges through one editable response comment. Continue to + // drain legacy eyes removals, but never create new eyes for these requests. + if (input.endpoint.provider === "github") return; + const record = await endpointRecord(input.endpoint.id); + if (!record) return; + // A live SDK object remains bound to the credentials that authenticated + // its callback. Preserve that original fence rather than sampling the + // endpoint again after a rotation and accidentally blessing an obsolete + // adapter with the new generation. + const context = input.runtimeContext ?? runtimeContextForRecord(record); + const [inserted] = await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "receipt_reaction"); + return tx + .insert(chatActions) + .values({ + companyId: input.endpoint.companyId, + endpointId: input.endpoint.id, + deliveryId: input.deliveryId, + kind: "receipt_reaction", + providerActionId: `receipt_reaction:${input.deliveryId}`, + payload: { + version: 1, + operation: "add", + threadId: input.thread.id, + messageId: input.message.id, + reaction: "eyes", + runtimeGeneration: context.generation, + credentialFingerprint: context.credentialFingerprint, + } satisfies ReceiptReactionPayload, + status: "received", + }) + .onConflictDoNothing() + .returning({ id: chatActions.id }); + }); + const actionId = + inserted?.id ?? + (await db + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.endpoint.id), + eq( + chatActions.providerActionId, + `receipt_reaction:${input.deliveryId}`, + ), + ), + ) + .then((rows) => rows[0]?.id ?? null)); + if (!actionId) return; + await options.receiptReactionTransportBarrier?.(); + await processReceiptReaction(actionId); + } + + async function processReceiptReaction(actionId: string): Promise { + const initialAction = await db + .select({ endpointId: chatActions.endpointId }) + .from(chatActions) + .where( + and( + eq(chatActions.id, actionId), + eq(chatActions.kind, "receipt_reaction"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!initialAction) return; + const initialEndpoint = await endpointRecord(initialAction.endpointId); + if (!initialEndpoint) return; + // Receipt reactions are GitHub repository mutations too. Serialize their + // final runtime-fence check and provider call with reconnect, secret + // rotation, pause, and removal so credentials cannot become obsolete + // between authorization and transport. + await withCredentialMutationLease(initialEndpoint.endpoint, (lease) => + processReceiptReactionWithCredentialLease(actionId, lease), + ); + } + + async function processReceiptReactionWithCredentialLease( + actionId: string, + credentialLease: CredentialMutationLeaseGuard, + ): Promise { + let action = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.id, actionId), + eq(chatActions.kind, "receipt_reaction"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!action || ["processed", "cancelled"].includes(action.status)) return; + const payload = receiptReactionPayload(action.payload); + if (!payload) { + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "receipt_reaction"); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { + code: "receipt_reaction_payload_invalid", + retryable: false, + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, action.id)); + }); + return; + } + if (payload.operation === "add" && action.deliveryId) { + const terminalRemoval = await db + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, action.endpointId), + eq( + chatActions.providerActionId, + `receipt_reaction_remove:${action.deliveryId}`, + ), + ), + ) + .then((rows) => rows[0] ?? null); + if (terminalRemoval) { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + ...(typeof action!.result?.attempts === "number" + ? { attempts: action!.result.attempts } + : {}), + code: "receipt_reaction_superseded_by_terminal_publication", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.status, action!.status), + ), + ); + await credentialLease.assertOwned(tx); + }); + return; + } + } + if (action.status === "processing") { + if (action.updatedAt > new Date(Date.now() - PROVIDER_EFFECT_STALE_MS)) + return; + // Adding or removing the same reaction is idempotent. A worker + // interruption can be retried safely without an ambiguous + // duplicate-message boundary. + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "receipt_reaction"); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { + ...(action.result ?? {}), + retryable: true, + retryAt: new Date().toISOString(), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + eq(chatActions.updatedAt, action.updatedAt), + ), + ); + }); + action = await db + .select() + .from(chatActions) + .where(eq(chatActions.id, action.id)) + .then((rows) => rows[0] ?? null); + if (!action) return; + } + if (action.status === "failed") { + if (action.result?.retryable !== true) return; + const retryAt = + typeof action.result.retryAt === "string" + ? new Date(action.result.retryAt) + : null; + if (retryAt && retryAt > new Date()) return; + } else if (action.status !== "received") { + return; + } + const attempt = + (typeof action.result?.attempts === "number" ? action.result.attempts : 0) + + 1; + const retainedGitHubReceipt = parseGitHubReceiptIdentity( + action.result?.githubReceipt, + ); + const [claimed] = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + const rows = await tx + .update(chatActions) + .set({ + status: "processing", + result: { + attempts: attempt, + ...(retainedGitHubReceipt + ? { githubReceipt: retainedGitHubReceipt } + : {}), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.status, action!.status), + ), + ) + .returning(); + await credentialLease.assertOwned(tx); + return rows; + }); + if (!claimed) return; + + let reactionProvider: ChatProvider | null = null; + let githubReceipt: GitHubReceiptIdentity | null = retainedGitHubReceipt; + try { + const record = await endpointRecord(action.endpointId); + if ( + !record || + !["verifying", "active"].includes(record.endpoint.status) || + runtimeGeneration(record.endpoint.setup) !== payload.runtimeGeneration || + credentialFingerprint(record.credentialSecretRefs) !== + payload.credentialFingerprint + ) { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + attempts: attempt, + code: "receipt_reaction_runtime_superseded", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ); + await credentialLease.assertOwned(tx); + }); + return; + } + reactionProvider = record.endpoint.provider; + // Resolve the adapter only after acquiring the credential lease and + // proving the action's durable generation/fingerprint is still current. + // Never reuse the live callback's adapter: it may hold an installation + // token minted by a superseded private key. + const endpointRuntime = await runtimeFor(record.endpoint); + const adapter = endpointRuntime.thread(payload.threadId).adapter; + await credentialLease.assertOwned(); + if (record.endpoint.provider === "slack") { + await endpointRuntime.applySlackReceiptReaction(payload, fetchImpl); + } else if (record.endpoint.provider === "github") { + const assertCurrent = async () => { + await credentialLease.assertOwned(); + const [current] = await db + .select({ endpoint: chatEndpoints, connection: toolConnections }) + .from(chatEndpoints) + .innerJoin( + toolConnections, + and( + eq(toolConnections.id, chatEndpoints.connectionId), + eq(toolConnections.companyId, chatEndpoints.companyId), + ), + ) + .where( + and( + eq(chatEndpoints.id, action!.endpointId), + eq(chatEndpoints.companyId, action!.companyId), + ), + ) + .limit(1); + if ( + !current || + !["active", "verifying"].includes(current.endpoint.status) || + !current.connection.enabled || + current.connection.status !== "active" || + runtimeGeneration(current.endpoint.setup) !== + payload.runtimeGeneration || + credentialFingerprint(current.connection.credentialSecretRefs) !== + payload.credentialFingerprint || + runtime.get(action!.endpointId) !== endpointRuntime + ) + throw new Error("GitHub receipt authority is no longer current"); + }; + const applied = await endpointRuntime.applyGitHubReceiptReaction( + { ...payload, ...(githubReceipt ? { githubReceipt } : {}) }, + assertCurrent, + fetchImpl, + ); + githubReceipt = parseGitHubReceiptIdentity(applied); + if ( + !githubReceipt || + (payload.operation === "add" && !githubReceipt.reactionId) + ) + throw new Error("GitHub receipt identity was not confirmed"); + } else if (payload.operation === "remove") { + await adapter.removeReaction( + payload.threadId, + payload.messageId, + payload.reaction, + ); + } else { + await adapter.addReaction( + payload.threadId, + payload.messageId, + payload.reaction, + ); + } + // The provider may already have accepted the idempotent reaction. Fence + // the local success receipt so a reclaimed lease cannot report success + // for an obsolete runtime. + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + const processed = await tx + .update(chatActions) + .set({ + status: "processed", + result: { + attempts: attempt, + ...(githubReceipt ? { githubReceipt } : {}), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (!processed.length) { + throw new Error( + "Receipt-reaction ownership changed before success settlement", + ); + } + if (action.deliveryId) { + await tx + .update(chatDeliveries) + .set({ redactedError: null, updatedAt: new Date() }) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.state, "processed"), + like(chatDeliveries.redactedError, "Receipt reaction %"), + ), + ); + } + await credentialLease.assertOwned(tx); + }); + } catch (error) { + if ( + payload.operation === "add" && + reactionProvider === "slack" && + isSlackReceiptReactionAlreadyApplied(error) + ) { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + const processed = await tx + .update(chatActions) + .set({ + status: "processed", + result: { + attempts: attempt, + code: "receipt_reaction_already_applied", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action!.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (!processed.length) { + throw new Error( + "Receipt-reaction ownership changed before idempotent settlement", + ); + } + if (action.deliveryId) { + await tx + .update(chatDeliveries) + .set({ redactedError: null, updatedAt: new Date() }) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.state, "processed"), + like(chatDeliveries.redactedError, "Receipt reaction %"), + ), + ); + } + await credentialLease.assertOwned(tx); + }); + return; + } + const disposition = classifyChatPublicationError(error, attempt); + const retryable = + disposition.kind === "retry" || + (disposition.kind === "delivery_unknown" && attempt < 3); + const retryAfterMs = + disposition.kind === "retry" + ? disposition.retryAfterMs + : 100 * 2 ** Math.max(0, attempt - 1); + const operationLabel = + payload.operation === "remove" ? " reaction removal" : " reaction"; + const diagnostic = + `Receipt${operationLabel} ${retryable ? "deferred" : "failed"} after ${attempt} attempt${attempt === 1 ? "" : "s"} (${disposition.kind}): ${redactError(error)}`.slice( + 0, + MAX_ERROR_TEXT, + ); + const retryAt = retryable + ? new Date(Date.now() + retryAfterMs).toISOString() + : null; + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await notifyChatActionWork(tx, "receipt_reaction"); + const [failed] = await tx + .update(chatActions) + .set({ + status: "failed", + result: { + attempts: attempt, + ...(githubReceipt ? { githubReceipt } : {}), + code: + payload.operation === "remove" + ? `receipt_reaction_removal_${disposition.kind}` + : `receipt_reaction_${disposition.kind}`, + redactedError: diagnostic, + retryable, + retryAt, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "processing"), + sql`(${chatActions.result}->>'attempts')::int = ${attempt}`, + ), + ) + .returning({ id: chatActions.id }); + if (failed && action.deliveryId) { + await tx + .update(chatDeliveries) + .set({ redactedError: diagnostic, updatedAt: new Date() }) + .where( + and( + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.state, "processed"), + ), + ); + } + await credentialLease.assertOwned(tx); + }); + logger.warn( + { + endpointId: action.endpointId, + deliveryId: action.deliveryId, + attempts: attempt, + disposition: disposition.kind, + operation: payload.operation, + retryAt, + }, + "external chat receipt reaction deferred", + ); + } + } + + async function processPendingReceiptReactions( + limit = 25, + onlyActionId?: string, + ) { + const actions = await db.select({ id: chatActions.id }).from(chatActions) + .where(and(dueChatAction("receipt_reaction"), onlyActionId ? eq(chatActions.id, onlyActionId) : undefined)) + .orderBy(asc(chatActions.createdAt)).limit(limit); + for (const action of actions) await processReceiptReaction(action.id); + return actions.length; + } + return { addReceiptReaction, processReceiptReaction, processPendingReceiptReactions }; +} diff --git a/server/src/services/chat-channels/runtime-types.ts b/server/src/services/chat-channels/runtime-types.ts index a764a0297eb..cd2f6881e9b 100644 --- a/server/src/services/chat-channels/runtime-types.ts +++ b/server/src/services/chat-channels/runtime-types.ts @@ -1,5 +1,5 @@ import type { Db, chatEndpoints } from "@paperclipai/db"; -import type { ToolCredentialSecretRef } from "@paperclipai/shared"; +import type { ToolCredentialSecretRef, ChatEndpointSetupState, ChatEndpointCallbackSurfaces } from "@paperclipai/shared"; import type { ChatEndpointRuntime } from "../voice/voice-runtime.js"; export type EndpointRow = typeof chatEndpoints.$inferSelect; @@ -36,3 +36,17 @@ export type EndpointRecord = { applicationId: string; credentialSecretRefs: ToolCredentialSecretRef[]; }; + +export type SlackCallbackSurface = keyof ChatEndpointCallbackSurfaces; + +export type SlackCallbackObservation = { url: string; observedAt: string }; + +export type InternalSetupState = ChatEndpointSetupState & { + photonIntakeAfter?: string; + runtimeGeneration?: number; + /** Binds pre-install URL verification to the signing secret later configured. */ + slackVerificationSigningFingerprint?: string; + slackCallbackSurfaces?: Partial< + Record + >; +}; diff --git a/server/src/services/chat-channels/setup-probes.ts b/server/src/services/chat-channels/setup-probes.ts new file mode 100644 index 00000000000..36d0f8f9f66 --- /dev/null +++ b/server/src/services/chat-channels/setup-probes.ts @@ -0,0 +1,353 @@ +import type { EndpointRow, DbOrTransaction } from "./runtime-types.js"; + +import { notifyChatEndpointWork } from "../chat-work-notifications.js"; + +import { chatCredentialMutationLease } from "../chat-credential-mutation-lease.js"; + +import { githubChatManagementService } from "../chat-github-management.js"; + +import { + and, + asc, + desc, + eq, + gte, + inArray, + isNotNull, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatDeliveries, + chatEndpoints, + chatIdentityLinks, + chatMessageLinks, + chatPublications, + heartbeatRuns, + issueComments, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpoint, + ChatEndpointSetupState, +} from "@paperclipai/shared"; + +import { + conflict, + forbidden, + notFound, +} from "../../errors.js"; + +import { + logActivity, +} from "../activity-log.js"; + +import { + githubRunReplyState, +} from "../chat-run-publications.js"; + +import type { EndpointRecord } from "./runtime-types.js"; + +type SetupProbesOptions = { + endpointRecord: (endpointId: string) => Promise; + withCredentialMutationLease: ReturnType; + fetchImpl: typeof fetch; + findAuthorizedIdentityLink: (endpoint: EndpointRow, userId: string) => Promise<{ id: string } | null>; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean }>; + get: (endpointId: string) => Promise; + setupTestActivationBarrier?: () => Promise; +}; + +export function createSetupProbes(db: Db, options: SetupProbesOptions) { + const { endpointRecord, withCredentialMutationLease, fetchImpl, findAuthorizedIdentityLink, runtimeGeneration, lockCurrentPrincipalAuthorization, get } = options; + + + async function setupTestStatus(endpointId: string, userId: string) { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const startedAt = record.endpoint.setup.testStartedAt; + if (!startedAt) return { messageReceivedAt: null }; + const links = await db.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and( + eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId), + eq(chatIdentityLinks.paperclipUserId, userId), eq(chatIdentityLinks.status, "linked"), + )); + if (!links.length) return { messageReceivedAt: null }; + const principalIds = links.map((link) => link.principalId); + const [delivery, command] = await Promise.all([ + db.select({ at: chatDeliveries.createdAt }).from(chatDeliveries).where(and( + eq(chatDeliveries.companyId, record.endpoint.companyId), eq(chatDeliveries.endpointId, endpointId), + inArray(chatDeliveries.principalId, principalIds), gte(chatDeliveries.createdAt, new Date(startedAt)), + inArray(chatDeliveries.eventKind, ["message", "mention"]), + )).orderBy(asc(chatDeliveries.createdAt)).limit(1).then((rows) => rows[0]), + db.select({ at: chatActions.createdAt }).from(chatActions).where(and( + eq(chatActions.companyId, record.endpoint.companyId), eq(chatActions.endpointId, endpointId), + inArray(chatActions.principalId, principalIds), eq(chatActions.kind, "slash_task_start"), gte(chatActions.createdAt, new Date(startedAt)), + )).orderBy(asc(chatActions.createdAt)).limit(1).then((rows) => rows[0]), + ]); + const at = [delivery?.at, command?.at].filter((value): value is Date => Boolean(value)).sort((a, b) => a.getTime() - b.getTime())[0]; + return { messageReceivedAt: at?.toISOString() ?? null }; + } + + async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }) { + const initial = await endpointRecord(endpointId); + if (!initial) throw notFound("Chat endpoint not found"); + return withCredentialMutationLease( + initial.endpoint, + async (credentialLease) => { + // Reload only after owning the same lane as reconnect, pause, removal, + // and credential rotation. Qualification and activation must describe + // one exact provider runtime, never a setup snapshot that a concurrent + // reconnect has already superseded. + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const endpoint = record.endpoint; + if (endpoint.status !== "verifying" || endpoint.setup.step !== "test") { + throw conflict("This connection is not waiting for a setup test", { + code: "chat_endpoint_not_testing", + }); + } + if (endpoint.provider === "github" && endpoint.setup.github) { + const verified = await githubChatManagementService(db, fetchImpl).verification(endpoint.id); + if (!verified.ready && !(finishOptions?.optionalSlackTestForUser && verified.connectionReady)) throw conflict("Finish verifying the App, repositories, and assigned agent's tools before activating this bot", { checks: verified.checks }); + } + const testStartedAtValue = endpoint.setup.testStartedAt; + const testStartedAt = testStartedAtValue + ? new Date(testStartedAtValue) + : null; + const optionalSlackTest = Boolean(finishOptions?.optionalSlackTestForUser); + if (optionalSlackTest) { + if (!["slack", "github"].includes(endpoint.provider) || !endpoint.setup.webhookVerifiedAt || !endpoint.providerAccountId) { + throw conflict("Verify the connection before finishing setup"); + } + const linked = await findAuthorizedIdentityLink(endpoint, finishOptions!.optionalSlackTestForUser); + if (!linked) throw forbidden("Connect your account before finishing setup"); + } + if (!optionalSlackTest) { + if ( + !endpoint.lastEventAt || + !testStartedAtValue || + !testStartedAt || + Number.isNaN(testStartedAt.getTime()) || + endpoint.lastEventAt < testStartedAt + ) { + throw conflict( + "Send the test message in the provider before completing setup", + { + code: "chat_test_message_missing", + }, + ); + } + const requiredTrigger = + ["telegram", "imessage-photon"].includes(endpoint.provider) + ? "direct_message" + : "subscribed_message"; + const qualifyingDelivery = await db + .select({ + id: chatDeliveries.id, + conversationId: chatDeliveries.conversationId, + processedAt: chatDeliveries.processedAt, + }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, endpoint.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + eq(chatDeliveries.state, "processed"), + gte(chatDeliveries.processedAt, testStartedAt), + sql`${chatDeliveries.normalizedEvent}->>'trigger' = ${requiredTrigger}`, + ), + ) + .orderBy(desc(chatDeliveries.processedAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + if ( + !qualifyingDelivery?.conversationId || + !qualifyingDelivery.processedAt + ) { + throw conflict( + ["telegram", "imessage-photon"].includes(endpoint.provider) + ? "Send the test direct message before completing setup" + : "Reply once without mentioning the agent before completing setup", + { code: "chat_test_follow_up_missing" }, + ); + } + const finalPublication = await db + .select({ + commentId: chatPublications.commentId, + payload: chatPublications.payload, + }) + .from(chatPublications) + .innerJoin( + issueComments, + and( + eq(issueComments.id, chatPublications.commentId), + eq(issueComments.companyId, chatPublications.companyId), + eq(issueComments.issueId, chatPublications.issueId), + eq(issueComments.authorType, "agent"), + eq(issueComments.authorAgentId, endpoint.assignedAgentId), + ), + ) + .innerJoin( + chatMessageLinks, + and( + eq(chatMessageLinks.companyId, chatPublications.companyId), + eq(chatMessageLinks.endpointId, chatPublications.endpointId), + eq( + chatMessageLinks.conversationId, + chatPublications.conversationId, + ), + eq(chatMessageLinks.deliveryId, qualifyingDelivery.id), + eq(chatMessageLinks.direction, "inbound"), + isNotNull(chatMessageLinks.commentId), + ), + ) + .innerJoin( + heartbeatRuns, + and( + eq(heartbeatRuns.id, issueComments.createdByRunId), + eq(heartbeatRuns.companyId, chatPublications.companyId), + eq(heartbeatRuns.agentId, endpoint.assignedAgentId), + eq(heartbeatRuns.status, "succeeded"), + eq( + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + sql`${chatPublications.issueId}::text`, + ), + or( + sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`, + sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'commentId'`, + sql`coalesce(${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, + ), + ), + ) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq( + chatPublications.conversationId, + qualifyingDelivery.conversationId, + ), + eq(chatPublications.state, "published"), + gte(chatPublications.publishedAt, qualifyingDelivery.processedAt), + ), + ) + .orderBy(desc(chatPublications.publishedAt)) + .then((rows) => + rows.find( + (row) => + row.payload.interactionId === undefined && + row.payload.progressState === undefined && + row.commentId !== null, + ), + ); + let githubToolReply = false; + if (!finalPublication && endpoint.provider === "github") { + const setupRuns = await db.select({ id: heartbeatRuns.id, issueId: chatConversations.issueId }) + .from(heartbeatRuns) + .innerJoin(chatMessageLinks, and( + eq(chatMessageLinks.companyId, endpoint.companyId), + eq(chatMessageLinks.endpointId, endpoint.id), + eq(chatMessageLinks.deliveryId, qualifyingDelivery.id), + eq(chatMessageLinks.direction, "inbound"), + or( + sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`, + sql`coalesce(${heartbeatRuns.contextSnapshot}->'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, + ), + )) + .innerJoin(chatConversations, and( + eq(chatConversations.id, qualifyingDelivery.conversationId), + eq(chatConversations.id, chatMessageLinks.conversationId), + eq(chatConversations.companyId, endpoint.companyId), + sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${chatConversations.issueId}::text`, + )) + .where(and(eq(heartbeatRuns.companyId, endpoint.companyId), + eq(heartbeatRuns.agentId, endpoint.assignedAgentId), eq(heartbeatRuns.status, "succeeded"))) + .orderBy(desc(heartbeatRuns.createdAt)).limit(20); + for (const run of setupRuns) { + if (await githubRunReplyState(db, { companyId: endpoint.companyId, endpointId: endpoint.id, + issueId: run.issueId, runId: run.id }) === "confirmed") { + githubToolReply = true; + break; + } + } + } + if (!finalPublication && !githubToolReply) { + throw conflict( + "Wait for the Paperclip agent to reply to the setup turn before completing setup", + { + code: "chat_test_round_trip_incomplete", + }, + ); + } + } + await options.setupTestActivationBarrier?.(); + const expectedGeneration = runtimeGeneration(endpoint.setup); + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + if (optionalSlackTest) { + const linked = await tx.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and( + eq(chatIdentityLinks.endpointId, endpoint.id), eq(chatIdentityLinks.paperclipUserId, finishOptions!.optionalSlackTestForUser), eq(chatIdentityLinks.status, "linked"), + )).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); + const [activated] = await tx + .update(chatEndpoints) + .set({ + status: "active", + setup: { + ...endpoint.setup, + step: "complete", + testStartedAt: null, + testSkipped: optionalSlackTest, + }, + healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected", + activatedAt: endpoint.activatedAt ?? new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(chatEndpoints.id, endpointId), + eq(chatEndpoints.status, "verifying"), + sql`${chatEndpoints.setup}->>'step' = 'test'`, + sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${expectedGeneration}`, + sql`${chatEndpoints.setup}->>'testStartedAt' = ${testStartedAtValue}`, + ), + ) + .returning({ id: chatEndpoints.id }); + if (!activated) { + throw conflict("This setup test was superseded by a reconnect", { + code: "chat_endpoint_test_superseded", + }); + } + await tx + .update(toolConnections) + .set({ + status: "active", + enabled: true, + healthStatus: "healthy", + healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected", + lastError: null, + healthCheckedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + if (optionalSlackTest) await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, actorType: "user", actorId: finishOptions!.optionalSlackTestForUser, + action: "chat.setup_completed", entityType: "chat_endpoint", entityId: endpointId, + details: { conversationTestOptional: true }, + }); + await credentialLease.assertOwned(tx); + }); + return get(endpointId); + }, + ); + } + return { setupTestStatus, test }; +} diff --git a/server/src/services/chat-channels/slack/callback-verification.ts b/server/src/services/chat-channels/slack/callback-verification.ts new file mode 100644 index 00000000000..ecddb2f665f --- /dev/null +++ b/server/src/services/chat-channels/slack/callback-verification.ts @@ -0,0 +1,300 @@ +import { isProvisionedSlackSetup } from "@paperclipai/shared"; +import type { EndpointRow, DbOrTransaction, SlackCallbackSurface, InternalSetupState } from "../runtime-types.js"; + +import { notifyChatVerificationWork } from "../../chat-work-notifications.js"; + +import { slackChatRegistrationService } from "../../chat-slack-registration.js"; +import { chatSlackRegistrations } from "@paperclipai/db"; + +import { runtimeCanonicalOrigin } from "../../cloud-runtime-identity.js"; + +import { + createHash, + createHmac, + timingSafeEqual, +} from "node:crypto"; +import { + and, + eq, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import { + logActivity, +} from "../../activity-log.js"; + +import { secretService } from "../../secrets.js"; + +function slackRequestSignatureIsValid( + request: Request, + body: string, + signingSecret: string, +): boolean { + const timestamp = request.headers.get("x-slack-request-timestamp"); + const signature = request.headers.get("x-slack-signature"); + if (!timestamp || !signature) return false; + const timestampSeconds = Number(timestamp); + if ( + !Number.isFinite(timestampSeconds) || + Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 300 + ) + return false; + const expected = `v0=${createHmac("sha256", signingSecret).update(`v0:${timestamp}:${body}`).digest("hex")}`; + try { + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } +} + +function slackRequestWorkspaceId( + body: string, + contentType: string, +): string | null { + let payload: unknown; + try { + if (contentType.includes("application/json")) { + payload = JSON.parse(body); + } else { + const form = new URLSearchParams(body); + const interactivePayload = form.get("payload"); + payload = interactivePayload + ? JSON.parse(interactivePayload) + : Object.fromEntries(form.entries()); + } + } catch { + return null; + } + if (!payload || typeof payload !== "object") return null; + const record = payload as Record; + const nestedId = (value: unknown): string | null => { + if (!value || typeof value !== "object") return null; + const id = (value as Record).id; + return typeof id === "string" && id.length > 0 ? id : null; + }; + const stringField = (key: string): string | null => { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : null; + }; + const authorization = Array.isArray(record.authorizations) + ? record.authorizations.find( + (value): value is Record => + Boolean(value) && typeof value === "object", + ) + : null; + const authorizationField = (key: string): string | null => { + const value = authorization?.[key]; + return typeof value === "string" && value.length > 0 ? value : null; + }; + + // A Grid event may carry both an enterprise id and the concrete workspace + // id. The bot identity is verified against auth.test's team_id, so prefer a + // team id and use the enterprise id only when Slack omitted team context. + return ( + stringField("team_id") ?? + nestedId(record.team) ?? + authorizationField("team_id") ?? + stringField("enterprise_id") ?? + nestedId(record.enterprise) ?? + authorizationField("enterprise_id") + ); +} + +function canonicalCallbackUrl(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + url.hostname = url.hostname.replace(/\.$/, ""); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +// TLS terminates at a reverse proxy in many self-hosted deployments. The +// transport scheme is not evidence of URL drift; authority and path still are. +// This is diagnostic only: it does not trust forwarded headers or change auth. +function slackCallbackMatchesPublicUrl(observed: string, current: string): boolean { + const canonical = canonicalCallbackUrl(observed); + if (canonical === current) return true; + if (!canonical) return false; + const observedUrl = new URL(canonical); + const currentUrl = new URL(current); + return observedUrl.protocol === "http:" + && currentUrl.protocol === "https:" + && observedUrl.host === currentUrl.host + && observedUrl.pathname === currentUrl.pathname; +} + +async function inspectSlackCallback( + request: Request, +): Promise { + const url = slackCallbackObservationUrl(request); + if (!url) return null; + const contentType = request.headers.get("content-type")?.toLowerCase() ?? ""; + let body: string; + try { + body = await request.text(); + } catch { + return null; + } + if (contentType.includes("application/json")) { + try { + const payload = JSON.parse(body) as { + type?: unknown; + challenge?: unknown; + }; + const isUrlVerification = + payload.type === "url_verification" && + typeof payload.challenge === "string" && + payload.challenge.length > 0; + if (isUrlVerification || payload.type === "event_callback") { + return { + surface: "events", + url, + isUrlVerification, + }; + } + } catch { + return null; + } + return null; + } + + const form = new URLSearchParams(body); + if (form.get("command")) { + return { surface: "slashCommands", url, isUrlVerification: false }; + } + const interactivePayload = form.get("payload"); + if (!interactivePayload) return null; + try { + const payload = JSON.parse(interactivePayload) as { type?: unknown }; + if ( + typeof payload.type === "string" && + [ + "block_actions", + "block_suggestion", + "dialog_submission", + "message_action", + "shortcut", + "view_closed", + "view_submission", + "workflow_step_edit", + ].includes(payload.type) + ) { + return { surface: "interactivity", url, isUrlVerification: false }; + } + } catch { + return null; + } + return null; +} + +type SlackCallbackInspection = { + surface: SlackCallbackSurface; + url: string; + isUrlVerification: boolean; +}; + +// Cloud records the public request host in dedicated headers so provider edges +// cannot replace it with their upstream host. Only use callback-health evidence on a +// claimed Cloud instance, after provider authentication succeeded. It must not +// change the Request passed to the adapter, signature verification, routing, +// board identity, or the configured URL used to generate callbacks. +function slackCallbackObservationUrl(request: Request): string | null { + const directUrl = canonicalCallbackUrl(request.url); + if (!directUrl || !runtimeCanonicalOrigin()) return directUrl; + const hasCloudEvidence = request.headers.has("x-paperclip-cloud-forwarded-host"); + const host = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-host" : "x-forwarded-host")?.trim(); + const protocol = request.headers.get(hasCloudEvidence ? "x-paperclip-cloud-forwarded-proto" : "x-forwarded-proto")?.trim(); + if (!host || /[\s/@\\?#,]/.test(host) || (protocol !== "https" && protocol !== "http")) return directUrl; + try { + const origin = new URL(`${protocol}://${host}`); + // Accept one authority only, never credentials, paths, queries, or lists. + if (!origin.host || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) return directUrl; + const observed = new URL(directUrl); + observed.protocol = origin.protocol; + observed.host = origin.host; + observed.port = origin.port; + return canonicalCallbackUrl(observed.toString()); + } catch { + return directUrl; + } +} +export { slackRequestSignatureIsValid, slackRequestWorkspaceId, canonicalCallbackUrl, slackCallbackMatchesPublicUrl, inspectSlackCallback }; + +type SlackCallbackVerificationOptions = { + resolveCredentialRefs: (endpoint: EndpointRow, refs: ToolCredentialSecretRef[], database?: DbOrTransaction) => Promise>; + getWebhookPublicBaseUrl: () => string | null; + scheduleMessageProcessing: (task: () => Promise) => void; + slackRegistration: Pick, "notifyVerified">; +}; + +export function createSlackCallbackVerification(db: Db, options: SlackCallbackVerificationOptions) { + const { resolveCredentialRefs, getWebhookPublicBaseUrl, scheduleMessageProcessing, slackRegistration } = options; + + + async function verifySlackRequestUrl(endpoint: EndpointRow, request: Request): Promise { + const callback = await inspectSlackCallback(request.clone()); + if (!callback?.isUrlVerification) return null; + const body = await request.clone().text(); + let notifyAccount = false; + // URL verification needs only the signing secret. Do not initialize the + // SDK (which calls Slack auth.test), wait for OAuth, or acquire the creation + // lease: Slack can deliver this check while app setup still owns that lease. + const response = await db.transaction(async tx => { + const [current] = await tx.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id)).for("no key update"); + if (!current || ["archived", "paused", "revoked"].includes(current.status)) return new Response("ignored", { status: 200 }); + const [connection] = await tx.select({ status: toolConnections.status, refs: toolConnections.credentialSecretRefs }).from(toolConnections) + .where(and(eq(toolConnections.id, current.connectionId), eq(toolConnections.companyId, current.companyId))); + if (!connection || connection.status === "archived") return new Response("ignored", { status: 200 }); + let secret = (await resolveCredentialRefs(current, connection.refs.filter(ref => ref.configPath.replace(/^credentials\./, "") === "signingSecret"), tx)).signingSecret; + if (!secret && isProvisionedSlackSetup(current.setup.slackSetupMethod)) { + const [registration] = await tx.select().from(chatSlackRegistrations) + .where(and(eq(chatSlackRegistrations.endpointId, current.id), eq(chatSlackRegistrations.companyId, current.companyId))); + if (registration?.status !== "removed" && registration?.secretIds.signingSecret) { + secret = await secretService(tx as unknown as Db).resolveSecretValue(current.companyId, registration.secretIds.signingSecret, "latest", { + accessContext: { consumerType: "system", consumerId: `slack-registration:${current.id}`, configPath: "slack_registration.signingSecret", actorType: "system" }, + }); + } + } + if (!secret) return new Response("Slack signing secret is not ready yet", { status: 503 }); + if (!slackRequestSignatureIsValid(request, body, secret)) return new Response("Invalid signature", { status: 401 }); + const workspaceId = slackRequestWorkspaceId(body, request.headers.get("content-type") ?? ""); + if (workspaceId && current.providerAccountId && workspaceId !== current.providerAccountId) return new Response("ignored", { status: 200 }); + const observedAt = new Date(); + const setup = current.setup as InternalSetupState; + const matchesCurrentUrl = slackCallbackMatchesPublicUrl(callback.url, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${current.publicId}/slack`); + await notifyChatVerificationWork(tx); + await tx.update(chatEndpoints).set({ setup: { + ...setup, + ...(matchesCurrentUrl ? { webhookVerifiedAt: setup.webhookVerifiedAt ?? observedAt.toISOString(), + slackVerificationSigningFingerprint: createHash("sha256").update(secret).digest("hex") } : {}), + slackCallbackSurfaces: { ...setup.slackCallbackSurfaces, events: { url: callback.url, observedAt: observedAt.toISOString() } }, + ...(matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus + ? { slackAccount: { ...setup.slackAccount, verificationStatus: "pending" as const } } : {}), + } as InternalSetupState, ...(matchesCurrentUrl ? { healthMessage: "Slack Events Request URL verified" } : {}), updatedAt: observedAt }).where(eq(chatEndpoints.id, current.id)); + if (matchesCurrentUrl && !setup.webhookVerifiedAt) await logActivity(tx as unknown as Db, { companyId: current.companyId, actorType: "system", actorId: "slack-webhook", + action: "chat_endpoint.webhook_verified", entityType: "tool_connection", entityId: current.connectionId, + details: { endpointId: current.id, provider: "slack" } }); + notifyAccount = matchesCurrentUrl && setup.slackAccount?.status === "linked" && !setup.slackAccount.verificationStatus; + return Response.json({ challenge: (JSON.parse(body) as { challenge: string }).challenge }); + }); + if (notifyAccount) scheduleMessageProcessing(() => slackRegistration.notifyVerified(endpoint.id)); + return response; + } + return { verifySlackRequestUrl }; +} diff --git a/server/src/services/chat-channels/slack/messages.ts b/server/src/services/chat-channels/slack/messages.ts index eba1e833f2f..dece4360827 100644 --- a/server/src/services/chat-channels/slack/messages.ts +++ b/server/src/services/chat-channels/slack/messages.ts @@ -41,3 +41,49 @@ export function slackMessageSentAt(raw: unknown, messageId: string): Date | null const date = new Date(Number(ts) * 1_000); return Number.isFinite(date.getTime()) ? date : null; } + +export function isSlackReceiptReactionAlreadyApplied(error: unknown): boolean { + const pending: Array<{ depth: number; value: unknown }> = [ + { depth: 0, value: error }, + ]; + const seen = new Set(); + 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; + data?: { error?: unknown }; + original?: unknown; + originalError?: unknown; + }; + if ( + record.code === "already_reacted" || + record.data?.error === "already_reacted" + ) { + return true; + } + for (const value of [record.cause, record.original, record.originalError]) { + pending.push({ depth: current.depth + 1, value }); + } + } + return false; +} + +export function slackThreadChannelId(threadId: string): string | null { + if (!threadId.startsWith("slack:")) return null; + const channelAndThread = threadId.slice("slack:".length); + const separator = channelAndThread.indexOf(":"); + const channelId = + separator === -1 ? channelAndThread : channelAndThread.slice(0, separator); + return channelId || null; +} diff --git a/server/src/services/chat-channels/slack/task-start.ts b/server/src/services/chat-channels/slack/task-start.ts index 0ccb3793616..309953a69f9 100644 --- a/server/src/services/chat-channels/slack/task-start.ts +++ b/server/src/services/chat-channels/slack/task-start.ts @@ -1073,3 +1073,21 @@ export function createSlackTaskStart(db: Db, options: SlackTaskStartOptions) { } return { latestSlackDmControlThreadId, finalizeSlackTaskStartFailure, admitConfirmedSlackTaskStart, processSlackTaskStart, processPendingSlackTaskStarts }; } + +export function slackTaskStartActivityStatus(action: { + status: string; + updatedAt: Date; +}): string { + const age = Date.now() - action.updatedAt.getTime(); + if (action.status === "validating" && age >= SLACK_COMMAND_POST_STALE_MS) { + return "queued"; + } + if ( + (action.status === "received" && age >= SLACK_COMMAND_POST_STALE_MS) || + (action.status === "resolving" && + age >= SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS) + ) { + return "delivery_unknown"; + } + return action.status; +} diff --git a/server/src/services/chat-channels/telegram/callback-notices.ts b/server/src/services/chat-channels/telegram/callback-notices.ts new file mode 100644 index 00000000000..fe926e033b1 --- /dev/null +++ b/server/src/services/chat-channels/telegram/callback-notices.ts @@ -0,0 +1,128 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction } from "../runtime-types.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, +} from "drizzle-orm"; + +import { + chatEndpointResources, + chatExternalPrincipals, + chatIdentityLinks, +} from "@paperclipai/db"; + +import { + type TelegramCallbackReceipt, +} from "../../chat-telegram-ephemeral.js"; + +type TelegramCallbackNoticeAuthorityOptions = { + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; linkedDenied: boolean; userId: string | null; sponsorUserId?: string | null }>; +}; + +export function createTelegramCallbackNoticeAuthority(options: TelegramCallbackNoticeAuthorityOptions) { + const { nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization } = options; + + + // This is permission to return one fixed, neutral callback denial, not + // permission to execute the denied action or publish to the conversation. + // Retain the initial identity-policy snapshot so queued notices cannot cross + // a subsequent actor/link/reach change. No provider I/O occurs under locks. + async function lockTelegramCallbackNoticeAuthority( + tx: DbTransaction, + endpoint: EndpointRow, + receipt: TelegramCallbackReceipt, + principalId: string | null, + ): Promise { + if ( + endpoint.provider !== "telegram" || + endpoint.status !== "active" || + endpoint.companyId !== receipt.companyId || + endpoint.id !== receipt.endpointId || + endpoint.botExternalId !== receipt.botUserId || + !endpoint.providerAccountId || + !principalId || + Date.now() < receipt.receivedAtMs || + Date.now() >= receipt.deadlineAtMs + ) + return null; + const resource = + receipt.chatType === "private" + ? null + : await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.providerResourceId, receipt.chatId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + receipt.chatType === "private" + ? !endpoint.allowDirectMessages + : !resource || !nonDirectDestinationAllowed(endpoint, resource) + ) + return null; + const principal = await tx + .select() + .from(chatExternalPrincipals) + .where( + and( + eq(chatExternalPrincipals.companyId, endpoint.companyId), + eq(chatExternalPrincipals.provider, endpoint.provider), + eq( + chatExternalPrincipals.providerAccountId, + endpoint.providerAccountId, + ), + eq(chatExternalPrincipals.id, principalId), + eq(chatExternalPrincipals.externalId, receipt.receiverUserId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!principal || principal.kind !== "user" || principal.isBot) return null; + const authorization = await lockCurrentPrincipalAuthorization( + tx, + endpoint, + principal.id, + ); + 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, principal.id), + ), + ) + .then((rows) => rows[0] ?? null); + return createHash("sha256") + .update( + JSON.stringify([ + endpoint.companyId, + endpoint.id, + receipt.botUserId, + resource?.id ?? null, + principal.id, + principal.externalId, + link, + authorization, + endpoint.allowUnlinkedPeople, + endpoint.sponsorUserId, + ]), + ) + .digest("hex"); + } + return { lockTelegramCallbackNoticeAuthority }; +} diff --git a/server/src/services/chat-channels/telegram/messages.ts b/server/src/services/chat-channels/telegram/messages.ts index 152b6478533..ac75997b145 100644 --- a/server/src/services/chat-channels/telegram/messages.ts +++ b/server/src/services/chat-channels/telegram/messages.ts @@ -1,3 +1,4 @@ +import type { Attachment } from "chat"; import { createHash } from "node:crypto"; import type { chatDeliveries } from "@paperclipai/db"; import type { ChatProvider } from "@paperclipai/shared"; @@ -221,3 +222,14 @@ export function telegramMessageSentAt(raw: unknown): Date | null { const sentAt = new Date(value * 1_000); return Number.isFinite(sentAt.getTime()) ? sentAt : null; } + +export function telegramMarkdownAttachment(text: string): Attachment { + const data = Buffer.from(text, "utf8"); + return { + data, + mimeType: "text/markdown; charset=utf-8", + name: "paperclip-response.md", + size: data.byteLength, + type: "file", + }; + } diff --git a/server/src/services/chat-slack-registration-cleanup.ts b/server/src/services/chat-slack-registration-cleanup.ts index 22a2b758598..97292a773a5 100644 --- a/server/src/services/chat-slack-registration-cleanup.ts +++ b/server/src/services/chat-slack-registration-cleanup.ts @@ -6,8 +6,11 @@ import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-le // Keep generic connection removal independent of the registration service's // authorization graph; access routes already load tool-access through the barrel. export async function removeSlackRegistration(db: Db, endpointId: string, lease: CredentialMutationLeaseGuard) { + await lease.assertOwned(); const vault = secretService(db); const [row] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); + const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); + if (endpoint) await db.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, endpoint.connectionId), like(toolOauthStates.state, "slack-manager.%"))); if (!row) return; await lease.assertOwned(); await db.update(chatSlackRegistrations).set({ status: "removed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); @@ -16,4 +19,4 @@ export async function removeSlackRegistration(db: Db, endpointId: string, lease: for (const id of Object.values(row.secretIds)) { await lease.assertOwned(); await vault.remove(id); } await lease.assertOwned(); await db.update(chatSlackRegistrations).set({ secretIds: {} }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - } +} diff --git a/server/src/services/chat-slack-registration.ts b/server/src/services/chat-slack-registration.ts index 905c7ab0346..ea187cfff2f 100644 --- a/server/src/services/chat-slack-registration.ts +++ b/server/src/services/chat-slack-registration.ts @@ -1,531 +1,3 @@ -import { notifyChatVerificationWork } from "./chat-work-notifications.js"; -import { createAgentAvatarPool } from "./agent-avatar-pool.js"; -import type { AgentAvatarRequest } from "./agent-avatars.js"; -import { removeSlackRegistration } from "./chat-slack-registration-cleanup.js"; -import { createHash, randomBytes } from "node:crypto"; -import { and, eq, gt, inArray, like, sql } from "drizzle-orm"; -import { agents, chatEndpoints, chatSlackRegistrations, companies, toolConnections, toolOauthStates, type Db } from "@paperclipai/db"; -import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, slackRegistrationErrorMessage, type SlackRegistrationInput, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState } from "@paperclipai/shared"; -import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js"; -import { accessService } from "./access.js"; -import { logActivity } from "./activity-log.js"; -import { logger } from "../middleware/logger.js"; -import { instanceSettingsService } from "./instance-settings.js"; -import { secretService } from "./secrets.js"; -import { writeConnectionCredential } from "./connection-credentials.js"; -import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-lease.js"; - -export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean } -type Registration = typeof chatSlackRegistrations.$inferSelect; -const prefix = "slack-install."; -const timeoutMs = 20_000; -const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code }); -const knownProviderErrors: Record = { - invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid", - token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid", - invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid", - ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied", - missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid", -}; - -export function slackRegistrationProjection(row: Registration): SlackRegistrationState { - return { - status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status, - appId: row.appId, - managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps", - errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode, - }; -} - -export function slackChatRegistrationService(db: Db, options: { - publicOrigin: () => string | null; - webhookOrigin: () => string | null; - fetch?: typeof fetch; - renderAvatar?: (request: AgentAvatarRequest) => Promise; - withLock: (endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise) => Promise; - runtimeSigningSecret: (endpointId: string) => Promise; - runtimeBotToken: (endpointId: string) => Promise; - canWelcome: (endpointId: string, account: SlackAccountState) => Promise; - linkInstaller: (endpointId: string, user: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; - configure: (endpointId: string, credentials: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; -}) { - const fetchImpl = options.fetch ?? fetch; - const vault = secretService(db); - let avatarPool: ReturnType | undefined; - function origin(value: string | null) { - if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app"); - const url = new URL(value); - if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) - throw badRequest("Slack setup requires a configured public HTTPS origin"); - return url.origin; - } - const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`; - async function registration(endpointId: string) { - return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0]; - } - async function endpoint(endpointId: string, actor: SlackSetupActor) { - if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); - const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance }) - .from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId))) - .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) - .where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack"))); - if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found"); - if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections")) - throw forbidden("Missing permission: tools:manage_connections"); - return row; - } - function assertOrigins(row: Registration, publicId: string) { - if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup."); - const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`; - if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected) - throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup."); - } - async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) { - await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId, - action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId, - details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } }); - } - async function api(method: string, fields: Record | FormData, bearer?: string) { - const response = await fetchImpl(`https://slack.com/api/${method}`, { - method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs), - headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }, - body: fields instanceof FormData ? fields : new URLSearchParams(fields), - }); - const result = object(await response.json()); - if (!response.ok || result.ok !== true) { - const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure"); - throw providerFailure(code); - } - return result; - } - async function saveSecrets(row: Registration, values: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, - patch: Partial = {}, account?: SlackAccountState) { - await db.transaction(async tx => { - await lease.assertOwned(tx); - const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; - if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); - const ids = { ...current.secretIds }; - for (const [key, value] of Object.entries(values)) { - const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration", - configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId }, - ...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) }); - ids[key] = result.secret.id; - } - await notifyChatVerificationWork(tx); - await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId)); - if (account) { - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - if (!saved || saved.status === "archived") throw conflict("Slack setup changed"); - // Reauthorization never changes an established personal account binding. - if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); - } - await lease.assertOwned(tx); - }); - } - async function readSecret(row: Registration, key: string) { - if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection."); - return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", { - accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" }, - }); - } - async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) { - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() }) - .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); - } - async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await endpoint(row.endpointId, actor); - await db.transaction(async tx => { - await lease.assertOwned(tx); - const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; - if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - if (!saved || saved.status === "archived" || saved.setup.slackSetupMethod !== "automatic") throw conflict("Slack setup changed. Resume the saved connection."); - await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() }) - .where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))); - await lease.assertOwned(tx); - }); - } - async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput) { - return options.withLock(endpointId, async lease => { - const current = await endpoint(endpointId, actor); - const previous = await registration(endpointId); - if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup."); - if (previous?.appId) { - if (previous.errorCode === "slack_manifest_update_pending") { - await configureManifest(previous, actor, input.credentials.configurationToken, lease); - } - return; - } - if (previous?.requestId === input.requestId) return; - if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) { - await setFailure(previous, "uncertain", "slack_creation_uncertain", lease); - return; - } - if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== "automatic") - throw conflict("Automatic creation is only available for a new Slack connection"); - const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp); - const redirect = callbackUri(); - const manifest = buildSlackAppManifest({ app, agentName: current.agentName, - webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect }); - // Slack cannot authenticate a challenge until its creation response gives us - // the signing secret. Add event subscriptions only after that secret is durable. - const { event_subscriptions: _events, ...creationSettings } = manifest.settings; - const creationManifest = { ...manifest, settings: creationSettings }; - try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); } - catch (error) { - if (error instanceof Error && "status" in error) throw error; - throw providerFailure("slack_provider_failure"); - } - // No provider creation has happened until this intent is durable. - await endpoint(endpointId, actor); - await lease.assertOwned(); - const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const, - manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect, - createdByUserId: actor.userId, errorCode: null, updatedAt: new Date() }; - const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values }) - .onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning(); - await audit(row, actor, "creation_started"); - let createdAppId: string; - try { - const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken); - const credentials = object(result.credentials); - if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id) - || typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id) - || typeof credentials.client_secret !== "string" || !credentials.client_secret - || typeof credentials.signing_secret !== "string" || !credentials.signing_secret) - throw new Error("Incomplete Slack creation response"); - await endpoint(endpointId, actor); - await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease, - { appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" }); - createdAppId = result.app_id; - } catch (error) { - // Only documented rejection responses prove that creation did not happen. - const code = object(object(error).details).code; - const rejected = typeof code === "string" && Object.values(knownProviderErrors).includes(code); - await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease); - await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain"); - return; - } - // The app and credentials are durable before optional icon provisioning. - // A retry/restart always reuses this app, even if icon upload is interrupted. - const created = { ...row, appId: createdAppId }; - if (!await configureManifest(created, actor, input.credentials.configurationToken, lease)) return; - await saveAvatar(created, { status: "pending" }, actor, lease); - let avatar: SlackAvatarState; - try { - await endpoint(endpointId, actor); - await lease.assertOwned(); - // Cloud ingress requires a tenant session. Send the preset PNG bytes so - // Slack does not need to fetch an authenticated Paperclip URL. - const render = options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render; - const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) }; - delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets. - const png = await render({ appearance, - size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" }); - await endpoint(endpointId, actor); - await lease.assertOwned(); - const upload = new FormData(); - upload.set("app_id", createdAppId); - upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png"); - await api("apps.icon.set", upload, input.credentials.configurationToken); - avatar = { status: "uploaded", uploadedAt: new Date().toISOString() }; - } catch { - // Provider echoes (including tokens) never become saved state or activity. - avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" }; - } - await saveAvatar(created, avatar, actor, lease); - try { await audit(created, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); } - catch { logger.warn({ endpointId, appId: createdAppId }, "Slack avatar activity could not be recorded"); } - // An audit outage cannot change the saved creation outcome. - try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); } - catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); } - }); - } - async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(row.endpointId, actor); - if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== "automatic") - throw conflict("Resume the saved Slack connection before configuring its app"); - assertOrigins(row, current.endpoint.publicId); - await lease.assertOwned(); - try { - await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken); - } catch { - // The pending marker was saved with the app secrets before dispatch. Retrying - // the same manifest is safe even when Slack's response was lost. - await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending"); - return false; - } - await endpoint(row.endpointId, actor); - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) - .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); - await audit(row, actor, "manifest_updated"); - return true; - } - async function install(endpointId: string, actor: SlackSetupActor) { - return options.withLock(endpointId, async lease => { - const current = await endpoint(endpointId, actor); - const row = await registration(endpointId); - if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it"); - if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it"); - if (current.endpoint.setup.slackSetupMethod !== "automatic" || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it"); - assertOrigins(row, current.endpoint.publicId); - const state = `${prefix}${randomBytes(32).toString("base64url")}`; - const expiresAt = new Date(Date.now() + 10 * 60_000); - const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[]; - await db.transaction(async tx => { - await lease.assertOwned(tx); - await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`))); - await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id, - codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri, - endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), - createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, - requestedScopes: scopes, expiresAt }); - await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - }); - const url = new URL("https://slack.com/oauth/v2/authorize"); - url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri, - ...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString(); - await audit(row, actor, "installation_started"); - return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() }; - }); - } - async function expiredReturn(state: string, actor: SlackSetupActor) { - if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null; - const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state)); - if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null; - const binding = object(JSON.parse(attempt.codeVerifier)); - const current = await endpoint(String(binding.endpointId), actor); - return options.withLock(current.endpoint.id, async lease => { - const latest = await endpoint(current.endpoint.id, actor); - const row = await registration(current.endpoint.id); - if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId - || latest.endpoint.setup.slackSetupMethod !== "automatic" || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null; - assertOrigins(row, latest.endpoint.publicId); - await lease.assertOwned(); - await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); - await setFailure(row, row.status, "slack_install_expired", lease); - await audit(row, actor, "installation_failed", "slack_install_expired"); - return returnPath(row.endpointId); - }); - } - async function pending(state: string, actor: SlackSetupActor) { - if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state"); - const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))); - if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) - throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again."); - const binding = object(JSON.parse(attempt.codeVerifier)); - const current = await endpoint(String(binding.endpointId), actor); - const row = await registration(current.endpoint.id); - if (!row || row.status === "removed" || current.endpoint.setup.slackSetupMethod !== "automatic" - || current.endpoint.status === "paused" - || current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked" - || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0) - || row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri - || row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id) - throw conflict("Slack setup changed. Resume the saved connection and install again."); - assertOrigins(row, current.endpoint.publicId); - return { attempt, current, row }; - } - async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await endpoint(row.endpointId, actor); - await db.transaction(async tx => { - await lease.assertOwned(tx); - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0]; - if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed"); - await notifyChatVerificationWork(tx); - await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); - await lease.assertOwned(tx); - }); - } - async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(row.endpointId, actor); - const account = current.endpoint.setup.slackAccount; - if (!account) throw providerFailure("slack_install_account_missing"); - if (account.status === "linked") return; - const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId); - const user = object((await api("users.info", { user: account.externalUserId }, token)).user); - if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing"); - await endpoint(row.endpointId, actor); - await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease); - await saveAccount(row, { ...account, status: "linked" }, actor, lease); - try { await audit(row, actor, "account_linked"); } - catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); } - } - async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") { - const current = await endpoint(row.endpointId, actor); - const account = current.endpoint.setup.slackAccount; - if (!account || account.status !== "linked") return; - if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return; - const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus"; - const status = account[field] ?? "pending"; - if (status === "sending") { - await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease); - return; // Never replay an ambiguous dispatch after a process restart. - } - if (status !== "pending") return; - await saveAccount(row, { ...account, [field]: "sending" }, actor, lease); - let result: SlackAccountState; - try { - await endpoint(row.endpointId, actor); - await lease.assertOwned(); - if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed"); - const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId, - text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance." - : `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`, - unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId)); - if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response"); - result = { ...account, [field]: "sent", dmChannelId: sent.channel }; - } catch (error) { - result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" }; - } - await saveAccount(row, result, actor, lease); - try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); } - catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); } - } - async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await sendSetupMessage(row, actor, lease, "welcome"); - await sendSetupMessage(row, actor, lease, "verification"); - } - async function notifyVerified(endpointId: string) { - return options.withLock(endpointId, async lease => { - const row = await registration(endpointId); - if (!row || row.status !== "configured") return; - const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); - const account = saved?.setup.slackAccount; - if (!account || !["verifying", "active"].includes(saved.status)) return; - await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification"); - }); - } - function pendingVerificationMessage() { - // A pending verification cannot progress until linking, welcome, and an - // authenticated callback finish. Their commits wake this lane. - return and(eq(chatSlackRegistrations.status, "configured"), - inArray(chatEndpoints.status, ["verifying", "active"]), - sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`, - sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`, - sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`, - sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`); - } - function pendingVerificationMessages(limit: number) { - return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints) - .innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId))) - .where(pendingVerificationMessage()).limit(limit); - } - async function nextVerificationMessageAt(): Promise { - return (await pendingVerificationMessages(1)).length ? Date.now() : null; - } - async function processPendingVerificationMessages(limit = 25) { - for (const row of await pendingVerificationMessages(limit)) { - try { await notifyVerified(row.endpointId); } - catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); } - } - } - async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(endpointId, actor); - const row = await registration(endpointId); - if (!row || row.status === "removed") throw conflict("Slack registration is unavailable"); - if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; } - if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it"); - try { - assertOrigins(row, current.endpoint.publicId); - const botToken = await readSecret(row, "botToken"); - const auth = await api("auth.test", {}, botToken); - if (auth.team_id !== row.workspaceId || auth.user_id !== row.botUserId) throw providerFailure("slack_install_identity_mismatch"); - await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret - ? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, { - assertOwned: async database => { - await lease.assertOwned(database); - await endpoint(endpointId, actor); - assertOrigins(row, current.endpoint.publicId); - }, - }); - await linkAccount(row, actor, lease); - } catch (error) { - const code = object(object(error).details).code; - const safeCode = code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed" - ? "slack_install_identity_mismatch" : code === "chat_provider_permissions_missing" ? "slack_install_scopes_missing" - : code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete"; - await setFailure(row, "credentials_saved", safeCode, lease); - await audit(row, actor, "configuration_failed", safeCode); - return; - } - await lease.assertOwned(); - await db.transaction(async tx => { - await notifyChatVerificationWork(tx); - await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - }); - // Runtime now owns a separate vaulted copy. Keep the registration IDs until - // deletion succeeds so cleanup itself is retryable. - await cleanupStaged(endpointId, lease); - try { await audit(row, actor, "installation_completed"); } - catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); } - await welcome(row, actor, lease); - } - async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) { - const row = await registration(endpointId); - if (!row || row.status !== "configured") return; - const ids = { ...row.secretIds }; - for (const key of ["botToken", "signingSecret"]) { - if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; } - } - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - } - async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) { - const before = await pending(state, actor); - return options.withLock(before.row.endpointId, async lease => { - const { row, attempt, current } = await pending(state, actor); - const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); - if (!claimed) throw conflict("This Slack authorization was already used"); - if (error) { - const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed"; - await setFailure(row, row.status, failureCode, lease); - await audit(row, actor, "installation_failed", failureCode); - return row.endpointId; - } - try { - if (!code || code.length > 4096) throw providerFailure("slack_install_failed"); - const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri }); - const teamId = object(result.team).id; - const installerId = object(result.authed_user).id; - if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-") - || typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id) - || row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id) - throw providerFailure("slack_install_identity_mismatch"); - const granted = new Set(String(result.scope ?? "").split(",")); - if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); - if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId - || current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id) - throw providerFailure("slack_install_identity_mismatch"); - if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing"); - await endpoint(row.endpointId, actor); - // Reauthorization reuses the runtime signing secret after staging was cleaned. - await saveSecrets(row, { botToken: result.access_token }, actor, lease, - { status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null }, - { externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); - } catch (failure) { - const code = object(object(failure).details).code; - const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed"; - await setFailure(row, row.status, safeCode, lease); - await audit(row, actor, "installation_failed", safeCode); - return row.endpointId; - } - await resumeLocked(row.endpointId, actor, lease); - return row.endpointId; - }); - } - const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease); - async function returnPath(endpointId: string) { - const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints) - .innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId)); - if (!row) throw notFound("Slack connection not found"); - return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`; - } - return { create, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt, - close: async () => { await avatarPool?.close(); }, - resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, lease => resumeLocked(endpointId, actor, lease)) }; -} +// Compatibility boundary: both provisioning paths converge on the same Slack runtime. +export { slackChatRegistrationService } from "./connectors/slack/setup/service.js"; +export { slackRegistrationProjection, type SlackSetupActor } from "./connectors/slack/setup/registration.js"; diff --git a/server/src/services/connectors/slack/setup/completion.ts b/server/src/services/connectors/slack/setup/completion.ts new file mode 100644 index 00000000000..afefa935789 --- /dev/null +++ b/server/src/services/connectors/slack/setup/completion.ts @@ -0,0 +1,164 @@ +import { notifyChatVerificationWork } from "../../../chat-work-notifications.js"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { chatEndpoints, chatSlackRegistrations } from "@paperclipai/db"; +import { SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES, type SlackAccountState } from "@paperclipai/shared"; +import { conflict, forbidden } from "../../../../errors.js"; +import { logger } from "../../../../middleware/logger.js"; +import { secretService } from "../../../secrets.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js"; +export function createSlackCompletion(store: SlackRegistrationStore) { + const { db, options, endpoint, registration, assertOrigins, api, readSecret, audit, setFailure } = store; + const vault = secretService(db); + async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await endpoint(row.endpointId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0]; + if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed"); + await notifyChatVerificationWork(tx); + await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); + await lease.assertOwned(tx); + }); + } + async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + const current = await endpoint(row.endpointId, actor); + const account = current.endpoint.setup.slackAccount; + if (!account) throw providerFailure("slack_install_account_missing"); + if (account.status === "linked") return; + const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId); + const user = object((await api("users.info", { user: account.externalUserId }, token)).user); + if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing"); + await endpoint(row.endpointId, actor); + await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease); + await saveAccount(row, { ...account, status: "linked" }, actor, lease); + try { await audit(row, actor, "account_linked"); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); } + } + async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") { + const current = await endpoint(row.endpointId, actor); + const account = current.endpoint.setup.slackAccount; + if (!account || account.status !== "linked") return; + if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return; + const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus"; + const status = account[field] ?? "pending"; + if (status === "sending") { + await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease); + return; // Never replay an ambiguous dispatch after a process restart. + } + if (status !== "pending") return; + await saveAccount(row, { ...account, [field]: "sending" }, actor, lease); + let result: SlackAccountState; + try { + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed"); + const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId, + text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance." + : `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`, + unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId)); + if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response"); + result = { ...account, [field]: "sent", dmChannelId: sent.channel }; + } catch (error) { + result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" }; + } + await saveAccount(row, result, actor, lease); + try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); } + } + async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await sendSetupMessage(row, actor, lease, "welcome"); + await sendSetupMessage(row, actor, lease, "verification"); + } + async function notifyVerified(endpointId: string) { + return options.withLock(endpointId, async lease => { + const row = await registration(endpointId); + if (!row || row.status !== "configured") return; + const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); + const account = saved?.setup.slackAccount; + if (!account || !["verifying", "active"].includes(saved.status)) return; + await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification"); + }); + } + function pendingVerificationMessages(limit: number) { + return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints) + .innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId))) + .where(and(eq(chatSlackRegistrations.status, "configured"), inArray(chatEndpoints.status, ["verifying", "active"]), + sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`, + sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`, + sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`, + sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`)).limit(limit); + } + async function nextVerificationMessageAt(): Promise { + return (await pendingVerificationMessages(1)).length ? Date.now() : null; + } + async function processPendingVerificationMessages(limit = 25) { + for (const row of await pendingVerificationMessages(limit)) { + try { await notifyVerified(row.endpointId); } + catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); } + } + } + async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + const current = await endpoint(endpointId, actor); + const row = await registration(endpointId); + if (!row || row.status === "removed") throw conflict("Slack registration is unavailable"); + if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; } + if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it"); + try { + assertOrigins(row, current.endpoint.publicId); + const botToken = await readSecret(row, "botToken"); + const auth = await api("auth.test", {}, botToken, row.managerGrantId ? [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES] : undefined); + if (auth.team_id !== row.workspaceId || row.botUserId && auth.user_id !== row.botUserId || typeof auth.user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(auth.user_id)) throw providerFailure("slack_install_identity_mismatch"); + if (row.managerGrantId) { + if (typeof auth.bot_id !== "string" || !/^B[A-Z0-9]+$/.test(auth.bot_id)) throw providerFailure("slack_install_identity_mismatch"); + const bot = object((await api("bots.info", { bot: auth.bot_id }, botToken)).bot); + if (bot.id !== auth.bot_id || bot.app_id !== row.appId || bot.user_id !== auth.user_id || bot.deleted !== false) throw providerFailure("slack_install_identity_mismatch"); + } + if (!row.botUserId) { + if (!row.managerGrantId) throw providerFailure("slack_install_identity_mismatch"); + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ botUserId: auth.user_id }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + } + await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret + ? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, { + assertOwned: async database => { + await lease.assertOwned(database); + await endpoint(endpointId, actor); + assertOrigins(row, current.endpoint.publicId); + }, + }); + await linkAccount(row, actor, lease); + } catch (error) { + const code = object(object(error).details).code; + const safeCode = code === "slack_configuration_token_invalid" ? "slack_install_token_invalid" : code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed" + ? "slack_install_identity_mismatch" : (code === "chat_provider_permissions_missing" || code === "slack_install_scopes_missing" || code === "slack_setup_permission_denied") ? "slack_install_scopes_missing" + : code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete"; + await setFailure(row, "credentials_saved", safeCode, lease); + await audit(row, actor, "configuration_failed", safeCode); + return; + } + await lease.assertOwned(); + await db.transaction(async tx => { + await notifyChatVerificationWork(tx); + await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + // Runtime now owns a separate vaulted copy. Keep the registration IDs until + // deletion succeeds so cleanup itself is retryable. + await cleanupStaged(endpointId, lease); + try { await audit(row, actor, "installation_completed"); } + catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); } + await welcome(row, actor, lease); + } + async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) { + const row = await registration(endpointId); + if (!row || row.status !== "configured") return; + const ids = { ...row.secretIds }; + for (const key of ["botToken", "signingSecret"]) { + if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; } + } + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + } + return { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt }; +} diff --git a/server/src/services/connectors/slack/setup/customer-owned.ts b/server/src/services/connectors/slack/setup/customer-owned.ts new file mode 100644 index 00000000000..6edd552d3fe --- /dev/null +++ b/server/src/services/connectors/slack/setup/customer-owned.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { chatSlackRegistrations } from "@paperclipai/db"; +import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, type SlackRegistrationInput, type SlackAvatarState } from "@paperclipai/shared"; +import { conflict } from "../../../../errors.js"; +import { logger } from "../../../../middleware/logger.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, knownProviderErrors, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js"; +export function createCustomerOwnedSetup(store: SlackRegistrationStore) { + const { db, options, endpoint, registration, callbackUri, origin, api, audit, saveSecrets, setFailure, saveAvatar, assertOrigins, renderAvatar } = store; + async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput, managed?: { grantId: string; workspaceId: string; request: typeof api }) { + return options.withLock(endpointId, async lease => { + const api = managed?.request ?? store.api; + const current = await endpoint(endpointId, actor); + const previous = await registration(endpointId); + const method = managed ? "managed" : "automatic"; + if (current.endpoint.setup.slackSetupMethod !== method) throw conflict("Resume the selected Slack setup method"); + if (previous && previous.managerGrantId !== (managed?.grantId ?? null)) throw conflict("Resume setup with the original Slack workspace"); + if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup."); + if (previous?.appId) { + if (previous.errorCode === "slack_manifest_update_pending") { + if (!await configureManifest(previous, actor, input.credentials.configurationToken, lease, method, api)) return; + if (current.endpoint.setup.slackAvatar?.status !== "uploaded") await configureAvatar(previous, actor, input.credentials.configurationToken, lease, api); + } + return; + } + if (previous?.requestId === input.requestId) return; + if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) { + await setFailure(previous, "uncertain", "slack_creation_uncertain", lease); + return; + } + if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== method) + throw conflict("Automatic creation is only available for a new Slack connection"); + const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp); + const redirect = callbackUri(); + const manifest = buildSlackAppManifest({ app, agentName: current.agentName, + webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect }); + // Slack cannot authenticate a challenge until its creation response gives us + // the signing secret. Add event subscriptions only after that secret is durable. + const { event_subscriptions: _events, ...creationSettings } = manifest.settings; + const creationManifest = { ...manifest, settings: creationSettings }; + try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); } + catch (error) { + if (error instanceof Error && "status" in error) throw error; + throw providerFailure("slack_provider_failure"); + } + // No provider creation has happened until this intent is durable. + await endpoint(endpointId, actor); + await lease.assertOwned(); + const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const, + manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect, + createdByUserId: actor.userId, managerGrantId: managed?.grantId ?? null, workspaceId: managed?.workspaceId ?? null, errorCode: null, updatedAt: new Date() }; + const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values }) + .onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning(); + await audit(row, actor, "creation_started"); + let createdAppId: string; + try { + const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken); + const credentials = object(result.credentials); + if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id) + || typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id) + || typeof credentials.client_secret !== "string" || !credentials.client_secret + || typeof credentials.signing_secret !== "string" || !credentials.signing_secret) + throw new Error("Incomplete Slack creation response"); + if (managed && result.team_id !== managed.workspaceId) throw new Error("Slack creation workspace mismatch"); + await endpoint(endpointId, actor); + await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease, + { appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" }); + createdAppId = result.app_id; + } catch (error) { + // Only documented rejection responses prove that creation did not happen. + const code = object(object(error).details).code; + const rejected = typeof code === "string" && [...Object.values(knownProviderErrors), "slack_manager_reauthorize"].includes(code); + await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease); + await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain"); + return; + } + // The app and credentials are durable before optional icon provisioning. + // A retry/restart always reuses this app, even if icon upload is interrupted. + const created = { ...row, appId: createdAppId }; + if (!await configureManifest(created, actor, input.credentials.configurationToken, lease, method, api)) return; + await configureAvatar(created, actor, input.credentials.configurationToken, lease, api); + // An audit outage cannot change the saved creation outcome. + try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); } + catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); } + }); + } + async function configureAvatar(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, api = store.api) { + const current = await endpoint(row.endpointId, actor); + await saveAvatar(row, { status: "pending" }, actor, lease); + let avatar: SlackAvatarState; + try { + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + // Cloud ingress requires a tenant session. Send the preset PNG bytes so + // Slack does not need to fetch an authenticated Paperclip URL. + const render = renderAvatar; + const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) }; + delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets. + const png = await render({ appearance, + size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" }); + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + const upload = new FormData(); + upload.set("app_id", row.appId!); + upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png"); + await api("apps.icon.set", upload, configurationToken); + avatar = { status: "uploaded", uploadedAt: new Date().toISOString() }; + } catch { + // Provider echoes (including tokens) never become saved state or activity. + avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" }; + } + await saveAvatar(row, avatar, actor, lease); + try { await audit(row, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId! }, "Slack avatar activity could not be recorded"); } + } + async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, method: "automatic" | "managed" = "automatic", api = store.api) { + const current = await endpoint(row.endpointId, actor); + if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== method) + throw conflict("Resume the saved Slack connection before configuring its app"); + assertOrigins(row, current.endpoint.publicId); + await lease.assertOwned(); + try { + await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken); + } catch { + // The pending marker was saved with the app secrets before dispatch. Retrying + // the same manifest is safe even when Slack's response was lost. + await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending"); + return false; + } + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); + await audit(row, actor, "manifest_updated"); + return true; + } + return { create, configureManifest }; +} diff --git a/server/src/services/connectors/slack/setup/development-manager.ts b/server/src/services/connectors/slack/setup/development-manager.ts new file mode 100644 index 00000000000..d4e71fd868a --- /dev/null +++ b/server/src/services/connectors/slack/setup/development-manager.ts @@ -0,0 +1,73 @@ +import { readFileSync, statSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; +import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js"; +import { createSlackSetupClient, object, providerFailure } from "./provider-client.js"; + +export type SlackManagerClient = Pick & { authorizationMode?: "code" }; + +// Explicit local qualification only. Production continues using the sealed Cloud +// broker; ordinary self-hosted installs neither load credentials nor contact it. +export function developmentSlackManager( + publicOrigin: () => string, + env: NodeJS.ProcessEnv = process.env, + fetchImpl: typeof fetch = fetch, +): SlackManagerClient | null { + if (env.NODE_ENV !== "development" || env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true" + || !env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG) return null; + let config: Record; + try { + const path = env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG; + if (!isAbsolute(path)) throw new Error(); + const stat = statSync(path); + if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error(); + config = object(JSON.parse(readFileSync(path, "utf8"))); + } catch { throw providerFailure("slack_managed_unavailable"); } + const { appId, clientId, clientSecret } = config; + if (typeof appId !== "string" || !/^A[A-Z0-9]+$/.test(appId) + || typeof clientId !== "string" || !/^\d+\.\d+$/.test(clientId) + || typeof clientSecret !== "string" || !clientSecret) throw providerFailure("slack_managed_unavailable"); + const callback = () => { + const url = new URL(publicOrigin()); + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw providerFailure("slack_managed_unavailable"); + return `${url.origin}/api/chat-slack/managed/oauth/callback`; + }; + const api = createSlackSetupClient(fetchImpl); + async function credentials(fields: Record, subject: string, companyId: string, initial: boolean): Promise { + let result: Record; + try { result = await api("oauth.v2.access", { client_id: clientId as string, client_secret: clientSecret as string, ...fields }); } + catch { throw providerFailure("slack_manager_reauthorize"); } + const user = initial ? object(result.authed_user) : result; + const scopes = typeof user.scope === "string" ? user.scope.split(/[, ]+/) : []; + const team = object(result.team); + if (typeof user.access_token !== "string" || !user.access_token || typeof user.refresh_token !== "string" || !user.refresh_token + || user.token_type !== "user" || typeof user.expires_in !== "number" || !Number.isFinite(user.expires_in) || user.expires_in <= 0 + || SLACK_MANAGER_SCOPES.some(scope => !scopes.includes(scope)) + || (initial && (result.app_id !== appId || typeof team.id !== "string" || !/^T[A-Z0-9]+$/.test(team.id) + || typeof team.name !== "string" || !team.name || typeof user.id !== "string" || !/^[UW][A-Z0-9]+$/.test(user.id)))) throw providerFailure("slack_manager_reauthorize"); + return { + v: 1, accessToken: user.access_token, refreshToken: user.refresh_token, tokenType: "user", + accessTokenExpiresAt: new Date(Date.now() + user.expires_in * 1000).toISOString(), refreshTokenExpiresAt: null, + scopes, subject, companyId, instanceId: "local-slack-qualification", environment: "development", provider: "slack", profile: "slack.manager", + ...(initial ? { slackManager: { appId: appId as string, workspaceId: team.id as string, workspaceName: team.name as string, userId: user.id as string } } : {}), + }; + } + return { + authorizationMode: "code", + async getSlackManagerAppId() { callback(); return appId as string; }, + async startAuthorization(values) { + if (values.returnUri !== callback() || values.profile !== "slack.manager") throw providerFailure("slack_manager_reauthorize"); + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.search = new URLSearchParams({ client_id: clientId as string, user_scope: SLACK_MANAGER_SCOPES.join(","), redirect_uri: callback(), state: values.returnState }).toString(); + return { authorizationUrl: url.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() }; + }, + async claim(values) { return credentials({ code: values.claimId, redirect_uri: callback() }, values.subject, values.companyId, true); }, + async refresh(values) { return credentials({ grant_type: "refresh_token", refresh_token: values.refreshToken }, values.subject, values.companyId, false); }, + async revoke(values) { + try { await api("auth.revoke", {}, values.token); } + catch { throw providerFailure("slack_manager_reauthorize"); } + }, + }; +} diff --git a/server/src/services/connectors/slack/setup/managed.ts b/server/src/services/connectors/slack/setup/managed.ts new file mode 100644 index 00000000000..fc529fc7b9b --- /dev/null +++ b/server/src/services/connectors/slack/setup/managed.ts @@ -0,0 +1,81 @@ +import { and, eq } from "drizzle-orm"; +import { chatActions, chatSlackRegistrations } from "@paperclipai/db"; +import { type SlackManagedProvisionInput, type SlackManagedResult } from "@paperclipai/shared"; +import { conflict } from "../../../../errors.js"; +import type { createCustomerOwnedSetup } from "./customer-owned.js"; +import type { createSlackManagerGrants } from "./manager-grants.js"; +import type { createSlackCompletion } from "./completion.js"; +import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js"; + +/** Managed provisioning ends at the same saved-credential boundary as child OAuth. */ +export function createManagedSlackSetup(store: SlackRegistrationStore, grants: ReturnType, + provisioner: ReturnType, completion: ReturnType, + installFallback: (endpointId: string, actor: SlackSetupActor) => Promise<{ authorizationUrl: string; expiresAt: string }>) { + async function provision(endpointId: string, actor: SlackSetupActor, input: SlackManagedProvisionInput): Promise { + const current = await store.endpoint(endpointId, actor); + if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method"); + const authorized = await grants.token(input.grantId, current.endpoint.companyId, actor); + await provisioner.create(endpointId, actor, { requestId: input.requestId, confirmedNoAppCreated: input.confirmedNoAppCreated, + credentials: { configurationToken: authorized.token } }, { grantId: authorized.grant.id, workspaceId: authorized.grant.workspaceId, request: (method, fields) => grants.request(input.grantId, current.endpoint.companyId, actor, method, fields) }); + let fallback = false; + await store.options.withLock(endpointId, async lease => { + const latest = await store.endpoint(endpointId, actor); + const row = await store.registration(endpointId); + if (!row?.appId || row.status === "removed" || row.errorCode === "slack_manifest_update_pending") return; + if (row.managerGrantId !== authorized.grant.id || latest.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Slack setup changed"); + store.assertOrigins(row, latest.endpoint.publicId); + const reconnectKey = input.reconnect ? `slack_managed_reconnect:${actor.userId}:${input.requestId}` : null; + const [previous] = reconnectKey ? await store.db.select().from(chatActions).where(and(eq(chatActions.companyId, row.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey))) : []; + async function completeSaved() { + await completion.resumeLocked(endpointId, actor, lease); + if (reconnectKey && (await store.registration(endpointId))?.status === "configured") { + await lease.assertOwned(); + await store.db.update(chatActions).set({ status: "processed", updatedAt: new Date() }).where(and(eq(chatActions.companyId, row!.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey))); + } + } + if (previous && row.status === "configured" && !["revoked", "attention"].includes(latest.endpoint.status)) { + await completeSaved(); return; + } + const needsReinstall = row.status === "configured" && (input.reconnect || ["revoked", "attention"].includes(latest.endpoint.status)) + || row.status === "credentials_saved" && ["slack_install_token_invalid", "slack_install_scopes_missing"].includes(row.errorCode ?? ""); + if (reconnectKey && !previous) { + // Atomically bind an explicit reconnect to its durable installation intent. + // Duplicate requests can return the saved connection without reinstalling. + await store.db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.insert(chatActions).values({ companyId: row.companyId, endpointId, kind: "slack_managed_reconnect", providerActionId: reconnectKey, status: "processing", payload: { grantId: row.managerGrantId, registrationRequestId: row.requestId } }); + if (needsReinstall) await tx.update(chatSlackRegistrations).set({ status: "install", errorCode: row.errorCode ?? "slack_install_failed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + } + if ((row.status === "configured" || row.status === "credentials_saved") && !needsReinstall) { + await completeSaved(); + return; + } + // Keep reinstall intent durable even if this dispatch fails or the process exits. + if (needsReinstall && (!reconnectKey || previous)) await store.setFailure(row, "install", row.errorCode ?? "slack_install_failed", lease); + const { grant } = await grants.token(input.grantId, row.companyId, actor); + try { + await lease.assertOwned(); + const result = await grants.request(grant.id, row.companyId, actor, "apps.managedInstall", { app_id: row.appId, team_id: grant.workspaceId }); + const botToken = object(result.api_access_tokens).bot_access_token; + if (result.app_id !== row.appId || result.team_id !== grant.workspaceId || typeof botToken !== "string" || !botToken.startsWith("xoxb-")) throw providerFailure("slack_install_identity_mismatch"); + if ((await grants.get(grant.id, row.companyId, actor)).status !== "active") throw providerFailure("slack_manager_reauthorize"); + await store.endpoint(endpointId, actor); + // Vault before auth.test/inventory so network failures never lose this installation. + await store.saveSecrets(row, { botToken }, actor, lease, { status: "credentials_saved", workspaceId: grant.workspaceId, errorCode: null }, + { externalUserId: grant.slackUserId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); + } catch (error) { + const code = object(object(error).details).code; + const safe = typeof code === "string" ? code : "slack_install_failed"; + await store.setFailure(row, needsReinstall ? "install" : row.status, safe, lease); + await store.audit(row, actor, "installation_failed", safe); + fallback = ["slack_approval_required", "slack_approval_pending", "slack_approval_denied"].includes(safe); + return; + } + await completeSaved(); + }); + // OAuth's state/actor binding is ours; never trust a provider-supplied redirect. + return fallback ? { authorization: await installFallback(endpointId, actor) } : {}; + } + return { provision }; +} diff --git a/server/src/services/connectors/slack/setup/manager-grants.ts b/server/src/services/connectors/slack/setup/manager-grants.ts new file mode 100644 index 00000000000..3d902d9928e --- /dev/null +++ b/server/src/services/connectors/slack/setup/manager-grants.ts @@ -0,0 +1,269 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { and, eq, gt, like, sql } from "drizzle-orm"; +import { chatEndpoints, chatSlackManagerGrants, chatSlackRegistrations, companySecrets, toolOauthStates } from "@paperclipai/db"; +import { SLACK_MANAGER_SCOPES, type SlackSetupOptions as SetupChoices } from "@paperclipai/shared"; +import { conflict, forbidden } from "../../../../errors.js"; +import { getCloudRuntimeIdentity } from "../../../cloud-runtime-identity.js"; +import { createPaperclipCloudConnector, paperclipCloudConnectorConfigFromEnv, type SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js"; +import { instanceSettingsService } from "../../../instance-settings.js"; +import { accessService } from "../../../access.js"; +import { secretService } from "../../../secrets.js"; +import { writeConnectionCredential } from "../../../connection-credentials.js"; +import { logActivity } from "../../../activity-log.js"; +import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js"; +import { developmentSlackManager, type SlackManagerClient } from "./development-manager.js"; + +export const SLACK_MANAGER_STATE_PREFIX = "slack-manager."; +export function createSlackManagerGrants(store: SlackRegistrationStore) { + const { db, options, endpoint, origin, api } = store; + type Database = Parameters[0]; + function broker(): SlackManagerClient | null { + if (options.managed) return options.managed.enabled ? options.managed.broker : null; + if (!getCloudRuntimeIdentity()) { + const development = developmentSlackManager(() => origin(options.publicOrigin())); + if (development) return development; + } + // Open-source instances never contact a hosted service to discover this path. + if (!getCloudRuntimeIdentity() || process.env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true") return null; + const config = paperclipCloudConnectorConfigFromEnv(); + return config ? createPaperclipCloudConnector({ config }) : null; + } + async function access(companyId: string, actor: SlackSetupActor, database: Database = db) { + if (!(await instanceSettingsService(database as typeof db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + if (!actor.bypassPermissionCheck && !await accessService(database as typeof db).hasPermission(companyId, "user", actor.userId, "tools:manage_connections")) throw forbidden("Missing permission: tools:manage_connections"); + } + // Deployment intent is stable across broker outages and preview rollbacks. + // A missing live profile pauses managed setup; it must not select token entry. + function defaultMethod(): "managed" | "automatic" { return broker() ? "managed" : "automatic"; } + async function available() { + const client = broker(); + if (!client || !(await instanceSettingsService(db).getExperimental()).enableChatConnectors) return false; + try { origin(options.publicOrigin()); origin(options.webhookOrigin()); return Boolean(await client.getSlackManagerAppId()); } + catch { return false; } + } + async function requiredBroker() { + if (!await available()) throw providerFailure("slack_managed_unavailable"); + return broker()!; + } + async function choices(companyId: string, actor: SlackSetupActor): Promise { + await access(companyId, actor); + const managedAvailable = await available(); + const managerAppId = managedAvailable ? await broker()!.getSlackManagerAppId() : null; + const grants = managedAvailable && managerAppId ? await db.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.status, "active"), eq(chatSlackManagerGrants.managerAppId, managerAppId))) : []; + return { managedAvailable, defaultMethod: defaultMethod(), workspaces: grants.map(g => ({ grantId: g.id, workspaceId: g.workspaceId, workspaceName: g.workspaceName, userId: g.slackUserId })) }; + } + const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/managed/oauth/callback`; + async function authorize(endpointId: string, actor: SlackSetupActor) { + const client = await requiredBroker(); + return options.withLock(endpointId, async lease => { + const current = await endpoint(endpointId, actor); + if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method"); + const row = await store.registration(endpointId); + const revisions = await db.select({ id: chatSlackManagerGrants.id, revision: chatSlackManagerGrants.revision }).from(chatSlackManagerGrants) + .where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId))); + const state = `${SLACK_MANAGER_STATE_PREFIX}${randomBytes(32).toString("base64url")}`; + const expiresAt = new Date(Date.now() + 10 * 60_000); + await lease.assertOwned(); + await db.insert(toolOauthStates).values({ state, companyId: current.endpoint.companyId, connectionId: current.connection.id, + codeVerifier: JSON.stringify({ endpointId, revisions, authorizationMode: client.authorizationMode ?? "claim", requestId: row?.requestId ?? null, callbackUri: callbackUri(), webhookOrigin: origin(options.webhookOrigin()), runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), + createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, requestedScopes: [...SLACK_MANAGER_SCOPES], expiresAt }); + try { return await client.startAuthorization({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, returnUri: callbackUri(), returnState: state }); } + catch { await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); throw providerFailure("slack_manager_reauthorize"); } + }); + } + async function pending(state: string, actor: SlackSetupActor, allowExpired = false) { + if (!/^slack-manager\.[A-Za-z0-9_-]{43}$/.test(state)) throw forbidden("Invalid Slack authorization state"); + const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), ...(allowExpired ? [] : [gt(toolOauthStates.expiresAt, new Date())]))); + if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) throw forbidden("Slack authorization expired or belongs to another session"); + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + const row = await store.registration(current.endpoint.id); + if (current.endpoint.setup.slackSetupMethod !== "managed" || current.connection.id !== attempt.connectionId || current.endpoint.companyId !== attempt.companyId + || ["paused", "revoked"].includes(current.endpoint.status) || row?.status === "removed" + || (binding.authorizationMode ?? "claim") !== (broker()?.authorizationMode ?? "claim") + || (row?.requestId ?? null) !== binding.requestId || binding.callbackUri !== callbackUri() || binding.webhookOrigin !== origin(options.webhookOrigin()) + || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0)) throw conflict("Slack setup changed. Resume authorization."); + return { attempt, current, row, binding }; + } + async function expiredReturn(state: string, actor: SlackSetupActor) { + const { current, attempt } = await pending(state, actor, true); + if (attempt.expiresAt.getTime() > Date.now()) return null; + return options.withLock(current.endpoint.id, async lease => { + const latest = await pending(state, actor, true); + await lease.assertOwned(); + await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); + await db.update(chatEndpoints).set({ setup: { ...latest.current.endpoint.setup, slackManagerError: "slack_install_expired" } }).where(eq(chatEndpoints.id, current.endpoint.id)); + return current.endpoint.id; + }); + } + async function complete(state: string, claimId: string | null, error: string | null, actor: SlackSetupActor, authorizationCode: string | null = null) { + const client = await requiredBroker(); + claimId = client.authorizationMode === "code" ? authorizationCode : claimId; + const before = await pending(state, actor); + return options.withLock(before.current.endpoint.id, async lease => { + const { current, row, binding } = await pending(state, actor); + await lease.assertOwned(); + const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); + if (!claimed) throw conflict("Slack authorization was already used"); + async function outcome(code: string | null) { + await lease.assertOwned(); + const latest = await endpoint(current.endpoint.id, actor); + await db.update(chatEndpoints).set({ setup: { ...latest.endpoint.setup, slackManagerError: code ?? undefined } }).where(eq(chatEndpoints.id, current.endpoint.id)); + if (code) await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorization_failed", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { code } }); + return current.endpoint.id; + } + if (error || !claimId || claimId.length > 4096) return outcome(error === "access_denied" ? "slack_install_declined" : "slack_manager_reauthorize"); + try { + let grant: SealedConnectorCredentials; + try { grant = await client.claim({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, claimId, redemptionId: randomUUID() }); } + catch { throw providerFailure("slack_manager_reauthorize"); } + const identity = grant.slackManager; + if (!identity || identity.appId !== await client.getSlackManagerAppId() || !/^A[A-Z0-9]+$/.test(identity.appId) || !/^T[A-Z0-9]+$/.test(identity.workspaceId) || !/^[UW][A-Z0-9]+$/.test(identity.userId) || typeof identity.workspaceName !== "string" || !identity.workspaceName || identity.workspaceName.length > 255) throw providerFailure("slack_manager_reauthorize"); + if (row?.managerGrantId) { + const existing = await get(row.managerGrantId, current.endpoint.companyId, actor); + if (existing.managerAppId !== identity.appId || existing.workspaceId !== identity.workspaceId || existing.slackUserId !== identity.userId) throw providerFailure("slack_install_identity_mismatch"); + } + await endpoint(current.endpoint.id, actor); + await access(current.endpoint.companyId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager:${current.endpoint.companyId}:${actor.userId}:${identity.appId}:${identity.workspaceId}`}, 0))`); + const [existing] = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.managerAppId, identity.appId), eq(chatSlackManagerGrants.workspaceId, identity.workspaceId))); + if (existing) { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${existing.id}`}, 0))`); + const [latest] = await tx.select().from(chatSlackManagerGrants).where(eq(chatSlackManagerGrants.id, existing.id)); + const revision = Array.isArray(binding.revisions) ? binding.revisions.map(object).find(value => value.id === existing.id)?.revision : undefined; + if (!latest || latest.revision !== revision) throw conflict("Workspace authorization changed. Start again."); + } + const [saved] = await tx.insert(chatSlackManagerGrants).values({ companyId: current.endpoint.companyId, userId: actor.userId, slackUserId: identity.userId, workspaceId: identity.workspaceId, workspaceName: identity.workspaceName, managerAppId: identity.appId }) + .onConflictDoUpdate({ target: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.userId, chatSlackManagerGrants.managerAppId, chatSlackManagerGrants.workspaceId], set: { updatedAt: new Date() } }).returning(); + // A reauthorization must never change which Slack person this grant represents. + if (saved.slackUserId !== identity.userId) throw conflict("Reconnect using the original Slack account"); + await access(current.endpoint.companyId, actor, tx); + await save(tx, saved, grant, actor); + if (row?.managerGrantId === saved.id) { + await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, current.endpoint.id), eq(chatSlackRegistrations.managerGrantId, saved.id), eq(chatSlackRegistrations.errorCode, "slack_manager_reauthorize"))); + } + }); + await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorized", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { workspaceId: identity.workspaceId, managerAppId: identity.appId } }); + return outcome(null); + } catch { return outcome("slack_manager_reauthorize"); } + }); + } + type Grant = typeof chatSlackManagerGrants.$inferSelect; + async function get(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + return readGrant(db, id, companyId, actor); + } + async function readGrant(database: Database, id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor, database); + const [grant] = await database.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.id, id), eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId))); + if (!grant) throw forbidden("This Slack workspace authorization belongs to another account"); + return grant; + } + async function save(tx: Parameters[0]>[0], row: Grant, credentials: SealedConnectorCredentials, actor: SlackSetupActor) { + if (credentials.tokenType !== "user" || !credentials.accessToken || !credentials.refreshToken || !credentials.accessTokenExpiresAt || !Number.isFinite(Date.parse(credentials.accessTokenExpiresAt)) || Date.parse(credentials.accessTokenExpiresAt) <= Date.now() || credentials.provider !== "slack" || credentials.profile !== "slack.manager" || credentials.subject !== row.userId || credentials.companyId !== row.companyId || SLACK_MANAGER_SCOPES.some(scope => !credentials.scopes.includes(scope))) throw providerFailure("slack_manager_reauthorize"); + const write = (key: string, value: string, secretId: string | null) => writeConnectionCredential(tx, { companyId: row.companyId, ownerUserId: row.userId, actor: { userId: actor.userId }, connectionName: "Slack workspace management", configPath: `slack_manager.${key}`, definitionKey: `slack_manager.${row.id}.${key}`, label: key, value, ...(secretId ? { existingRef: { secretId, configPath: `slack_manager.${key}` } } : {}) }); + const accessToken = await write("access_token", credentials.accessToken, row.accessSecretId); + const refreshToken = credentials.refreshToken ? await write("refresh_token", credentials.refreshToken, row.refreshSecretId) : null; + await tx.update(chatSlackManagerGrants).set({ accessSecretId: accessToken.secret.id, refreshSecretId: refreshToken?.secret.id ?? row.refreshSecretId, expiresAt: credentials.accessTokenExpiresAt ? new Date(credentials.accessTokenExpiresAt) : null, status: "active", revision: row.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, row.id)); + } + async function secret(row: Grant, id: string | null, database: Database = db) { + if (!id) throw providerFailure("slack_manager_reauthorize"); + const [ref] = await database.select().from(companySecrets).where(and(eq(companySecrets.id, id), eq(companySecrets.companyId, row.companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, row.userId))); + if (!ref?.userSecretDefinitionId) throw providerFailure("slack_manager_reauthorize"); + // Owner-scoped system consumer; the durable grant is its authority, not a tool binding. + const value = await secretService(database).resolveUserSecretValue(row.companyId, { definitionId: ref.userSecretDefinitionId, responsibleUserId: row.userId }, { consumerType: "system", consumerId: `slack-manager:${row.id}`, actorType: "user", actorId: row.userId, responsibleUserId: row.userId }); + if (!value) throw providerFailure("slack_manager_reauthorize"); + return value.value; + } + const tokenRequests = new Map>(); + async function token(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + const client = await requiredBroker(); + const managerAppId = await client.getSlackManagerAppId(); + const key = `${companyId}:${actor.userId}:${id}`; + const running = tokenRequests.get(key); + if (running) return running; + const operation = (async () => { + // Commit the single-use refresh claim before dispatch, without holding a + // pool connection while another query or the broker needs it. + const prepared = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const grant = await readGrant(tx, id, companyId, actor); + if (grant.managerAppId !== managerAppId || grant.status !== "active") throw providerFailure("slack_manager_reauthorize"); + if (!grant.expiresAt || grant.expiresAt.getTime() > Date.now() + 60_000) return { grant, token: await secret(grant, grant.accessSecretId, tx) }; + const refreshToken = await secret(grant, grant.refreshSecretId, tx); + const [claimed] = await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: grant.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)).returning(); + return { grant: claimed!, refreshToken }; + }); + if ("token" in prepared) return { grant: prepared.grant, token: prepared.token! }; + try { + const refreshed = await client.refresh({ profile: "slack.manager", subject: actor.userId, companyId, refreshToken: prepared.refreshToken }); + await access(companyId, actor); + return await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const current = await readGrant(tx, id, companyId, actor); + if (current.status !== "reauthorize" || current.revision !== prepared.grant.revision) throw providerFailure("slack_manager_reauthorize"); + await save(tx, current, refreshed, actor); + return { grant: await readGrant(tx, id, companyId, actor), token: refreshed.accessToken }; + }); + } catch { throw providerFailure("slack_manager_reauthorize"); } + })(); + tokenRequests.set(key, operation); + try { return await operation; } finally { if (tokenRequests.get(key) === operation) tokenRequests.delete(key); } + } + async function revoke(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + let providerRevoked = true; + const grant = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const current = await readGrant(tx, id, companyId, actor); + await tx.update(chatSlackManagerGrants).set({ status: "revoked", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)); + await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.companyId, companyId), eq(toolOauthStates.createdByActorId, actor.userId), like(toolOauthStates.state, `${SLACK_MANAGER_STATE_PREFIX}%`))); + const client = broker(); + for (const secretId of [current.accessSecretId, current.refreshSecretId]) if (secretId) { + try { + if (!client) throw providerFailure("slack_managed_unavailable"); + await client.revoke({ subject: actor.userId, companyId, profile: "slack.manager", token: await secret(current, secretId, tx) }); + } catch { providerRevoked = false; } + await secretService(tx).remove(secretId); + } + await tx.update(chatSlackManagerGrants).set({ accessSecretId: null, refreshSecretId: null }).where(eq(chatSlackManagerGrants.id, id)); + return current; + }); + await logActivity(db, { companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_revoked", entityType: "slack_manager_grant", entityId: id, details: { workspaceId: grant.workspaceId, providerRevoked } }); + } + async function request(id: string, companyId: string, actor: SlackSetupActor, method: string, fields: Record | FormData) { + const authorized = await token(id, companyId, actor); + const { grant } = authorized; + // Slack shares a method budget across manager tokens in the same workspace. + // Persist Retry-After across grants and process restarts; never retry a dispatch. + const result = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-api:${companyId}:${grant.managerAppId}:${grant.workspaceId}:${method}`}, 0))`); + // Serialize provider dispatch against refresh/revocation for this personal grant. + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const peers = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.managerAppId, grant.managerAppId), eq(chatSlackManagerGrants.workspaceId, grant.workspaceId))).orderBy(chatSlackManagerGrants.id); + if (peers.some(peer => Date.parse(peer.rateLimits[method] ?? "") > Date.now())) return { error: providerFailure("slack_setup_rate_limited") }; + const current = await readGrant(tx, id, companyId, actor); + if (current.status !== "active") return { error: providerFailure("slack_manager_reauthorize") }; + try { return { value: await api(method, fields, await secret(current, current.accessSecretId, tx)) }; } + catch (error) { + const details = object(object(error).details); + if (details.code === "slack_setup_rate_limited") { + const until = new Date(Date.now() + Math.min(Number(details.retryAfterSeconds) || 60, 86_400) * 1000).toISOString(); + for (const peer of peers) await tx.update(chatSlackManagerGrants).set({ rateLimits: sql`${chatSlackManagerGrants.rateLimits} || ${JSON.stringify({ [method]: until })}::jsonb` }).where(eq(chatSlackManagerGrants.id, peer.id)); + } + if (details.code === "slack_configuration_token_invalid") { + await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)); + return { error: providerFailure("slack_manager_reauthorize") }; + } + return { error }; + } + }); + if ("error" in result) throw result.error; + return result.value!; + } + return { choices, defaultMethod, available, authorize, pending, expiredReturn, complete, get, token, revoke, request }; +} diff --git a/server/src/services/connectors/slack/setup/provider-client.ts b/server/src/services/connectors/slack/setup/provider-client.ts new file mode 100644 index 00000000000..bc051d651cd --- /dev/null +++ b/server/src/services/connectors/slack/setup/provider-client.ts @@ -0,0 +1,58 @@ +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { unprocessable } from "../../../../errors.js"; +export const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +export const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code }); +export const knownProviderErrors: Record = { + app_approval_request_eligible: "slack_approval_required", app_approval_request_pending: "slack_approval_pending", + app_approval_request_denied: "slack_approval_denied", admin_approval_required: "slack_approval_required", + manager_app_not_eligible: "slack_managed_unavailable", feature_not_enabled: "slack_managed_unavailable", + invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid", + token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid", + invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid", + ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied", + missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid", +}; + +const timeoutMs = 20_000; +export function createSlackSetupClient(fetchImpl: typeof fetch = fetch) { + return async function api(method: string, fields: Record | FormData, bearer?: string, requiredScopes?: readonly string[]) { + const response = await fetchImpl(`https://slack.com/api/${method}`, { + method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs), + headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }, + body: fields instanceof FormData ? fields : new URLSearchParams(fields), + }); + // Bound the streamed response as well as the request lifetime. + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + if (reader) { + try { + while (true) { + const part = await reader.read(); + if (part.done) break; + size += part.value.byteLength; + if (size > 1_048_576) { await reader.cancel(); throw providerFailure("slack_provider_failure"); } + chunks.push(part.value); + } + } finally { reader.releaseLock(); } + } + let result: Record; + try { result = object(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } + catch { result = {}; } + if (!response.ok || result.ok !== true) { + const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure"); + const retry = Number(response.headers.get("retry-after")); + if (response.status === 429 || result.error === "ratelimited") { + throw unprocessable(slackRegistrationErrorMessage("slack_setup_rate_limited"), { + code: "slack_setup_rate_limited", retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? Math.ceil(retry) : 60, + }); + } + throw providerFailure(code); + } + if (requiredScopes) { + const granted = new Set((response.headers.get("x-oauth-scopes") ?? "").split(/[, ]+/)); + if (requiredScopes.some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); + } + return result; + } +} diff --git a/server/src/services/connectors/slack/setup/registration.ts b/server/src/services/connectors/slack/setup/registration.ts new file mode 100644 index 00000000000..ca44f0e7f50 --- /dev/null +++ b/server/src/services/connectors/slack/setup/registration.ts @@ -0,0 +1,136 @@ +import { notifyChatVerificationWork } from "../../../chat-work-notifications.js"; +import { createAgentAvatarPool } from "../../../agent-avatar-pool.js"; +import type { AgentAvatarRequest } from "../../../agent-avatars.js"; +import { and, eq } from "drizzle-orm"; +import { agents, chatEndpoints, chatSlackRegistrations, toolConnections, type Db } from "@paperclipai/db"; +import { slackRegistrationErrorMessage, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState, isProvisionedSlackSetup } from "@paperclipai/shared"; +import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../../errors.js"; +import { accessService } from "../../../access.js"; +import { logActivity } from "../../../activity-log.js"; +import { instanceSettingsService } from "../../../instance-settings.js"; +import { secretService } from "../../../secrets.js"; +import { writeConnectionCredential } from "../../../connection-credentials.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; + +export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean } +export type Registration = typeof chatSlackRegistrations.$inferSelect; + + +export { object, providerFailure, knownProviderErrors } from "./provider-client.js"; +import { object } from "./provider-client.js"; + +export function slackRegistrationProjection(row: Registration): SlackRegistrationState { + return { + status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status, + appId: row.appId, + ...(row.managerGrantId ? { managerGrantId: row.managerGrantId } : {}), + managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps", + errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode, + }; +} + +import { createSlackSetupClient } from "./provider-client.js"; +export type SlackSetupOptions = { + managed?: { enabled: boolean; broker: import("../../../paperclip-cloud-connector.js").PaperclipCloudConnector }; + publicOrigin: () => string | null; + webhookOrigin: () => string | null; + fetch?: typeof fetch; + renderAvatar?: (request: AgentAvatarRequest) => Promise; + withLock: (endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise) => Promise; + runtimeSigningSecret: (endpointId: string) => Promise; + runtimeBotToken: (endpointId: string) => Promise; + canWelcome: (endpointId: string, account: SlackAccountState) => Promise; + linkInstaller: (endpointId: string, user: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; + configure: (endpointId: string, credentials: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; +}; +export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions) { + const fetchImpl = options.fetch ?? fetch; + const vault = secretService(db); + let avatarPool: ReturnType | undefined; + function origin(value: string | null) { + if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app"); + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) + throw badRequest("Slack setup requires a configured public HTTPS origin"); + return url.origin; + } + const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`; + async function registration(endpointId: string) { + return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0]; + } + async function endpoint(endpointId: string, actor: SlackSetupActor) { + if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance }) + .from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId))) + .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) + .where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack"))); + if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found"); + if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections")) + throw forbidden("Missing permission: tools:manage_connections"); + return row; + } + function assertOrigins(row: Registration, publicId: string) { + if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup."); + const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`; + if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected) + throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup."); + } + async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) { + await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId, + action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId, + details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } }); + } + const api = createSlackSetupClient(fetchImpl); + async function saveSecrets(row: Registration, values: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, + patch: Partial = {}, account?: SlackAccountState) { + await db.transaction(async tx => { + await lease.assertOwned(tx); + const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; + if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); + const ids = { ...current.secretIds }; + for (const [key, value] of Object.entries(values)) { + const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration", + configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId }, + ...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) }); + ids[key] = result.secret.id; + } + await notifyChatVerificationWork(tx); + await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId)); + if (account) { + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + if (!saved || saved.status === "archived") throw conflict("Slack setup changed"); + // Reauthorization never changes an established personal account binding. + if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); + } + await lease.assertOwned(tx); + }); + } + async function readSecret(row: Registration, key: string) { + if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection."); + return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", { + accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" }, + }); + } + async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) { + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); + } + async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await endpoint(row.endpointId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; + if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + if (!saved || saved.status === "archived" || !isProvisionedSlackSetup(saved.setup.slackSetupMethod)) throw conflict("Slack setup changed. Resume the saved connection."); + await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() }) + .where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))); + await lease.assertOwned(tx); + }); + } + const renderAvatar = (request: AgentAvatarRequest) => (options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render)(request); + return { db, options, origin, callbackUri, registration, endpoint, assertOrigins, audit, api, saveSecrets, readSecret, setFailure, saveAvatar, renderAvatar, + close: async () => { await avatarPool?.close(); } }; +} +export type SlackRegistrationStore = ReturnType; diff --git a/server/src/services/connectors/slack/setup/service.ts b/server/src/services/connectors/slack/setup/service.ts new file mode 100644 index 00000000000..f6b61281aea --- /dev/null +++ b/server/src/services/connectors/slack/setup/service.ts @@ -0,0 +1,151 @@ +import { isProvisionedSlackSetup } from "@paperclipai/shared"; +import { createSlackManagerGrants } from "./manager-grants.js"; +import { createManagedSlackSetup } from "./managed.js"; +import { randomBytes } from "node:crypto"; +import { and, eq, gt, like } from "drizzle-orm"; +import { chatEndpoints, chatSlackRegistrations, companies, toolOauthStates, type Db } from "@paperclipai/db"; +import { badRequest, conflict, forbidden, notFound } from "../../../../errors.js"; +import { removeSlackRegistration } from "../../../chat-slack-registration-cleanup.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, providerFailure, createSlackRegistrationStore, type SlackSetupOptions, type SlackSetupActor } from "./registration.js"; +import { createCustomerOwnedSetup } from "./customer-owned.js"; +import { createSlackCompletion } from "./completion.js"; +const prefix = "slack-install."; +export function slackChatRegistrationService(db: Db, options: SlackSetupOptions) { + const store = createSlackRegistrationStore(db, options); + const { endpoint, registration, assertOrigins, callbackUri, audit, api, readSecret, setFailure, saveSecrets } = store; + const provisioner = createCustomerOwnedSetup(store); + const { create } = provisioner; + const completion = createSlackCompletion(store); + const { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt } = completion; + const managerGrants = createSlackManagerGrants(store); + const managed = createManagedSlackSetup(store, managerGrants, provisioner, completion, install); + async function install(endpointId: string, actor: SlackSetupActor) { + return options.withLock(endpointId, async lease => { + const current = await endpoint(endpointId, actor); + const row = await registration(endpointId); + if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it"); + if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it"); + if (!isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod) || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it"); + if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + assertOrigins(row, current.endpoint.publicId); + const state = `${prefix}${randomBytes(32).toString("base64url")}`; + const expiresAt = new Date(Date.now() + 10 * 60_000); + const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[]; + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`))); + await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id, + codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri, + endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), + createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, + requestedScopes: scopes, expiresAt }); + await tx.update(chatSlackRegistrations).set({ errorCode: row.managerGrantId ? row.errorCode : null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri, + ...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString(); + await audit(row, actor, "installation_started"); + return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() }; + }); + } + async function expiredReturn(state: string, actor: SlackSetupActor) { + if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null; + const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state)); + if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null; + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + return options.withLock(current.endpoint.id, async lease => { + const latest = await endpoint(current.endpoint.id, actor); + const row = await registration(current.endpoint.id); + if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId + || !isProvisionedSlackSetup(latest.endpoint.setup.slackSetupMethod) || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null; + assertOrigins(row, latest.endpoint.publicId); + await lease.assertOwned(); + await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); + await setFailure(row, row.status, "slack_install_expired", lease); + await audit(row, actor, "installation_failed", "slack_install_expired"); + return returnPath(row.endpointId); + }); + } + async function pending(state: string, actor: SlackSetupActor) { + if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state"); + const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))); + if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) + throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again."); + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + const row = await registration(current.endpoint.id); + if (!row || row.status === "removed" || !isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod) + || current.endpoint.status === "paused" + || current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked" + || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0) + || row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri + || row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id) + throw conflict("Slack setup changed. Resume the saved connection and install again."); + if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + assertOrigins(row, current.endpoint.publicId); + return { attempt, current, row }; + } + async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) { + const before = await pending(state, actor); + return options.withLock(before.row.endpointId, async lease => { + const { row, attempt, current } = await pending(state, actor); + const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); + if (!claimed) throw conflict("This Slack authorization was already used"); + if (error) { + const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed"; + await setFailure(row, row.status, failureCode, lease); + await audit(row, actor, "installation_failed", failureCode); + return row.endpointId; + } + try { + if (!code || code.length > 4096) throw providerFailure("slack_install_failed"); + const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri }); + const teamId = object(result.team).id; + const installerId = object(result.authed_user).id; + if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-") + || typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id) + || row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id) + throw providerFailure("slack_install_identity_mismatch"); + const granted = new Set(String(result.scope ?? "").split(",")); + if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); + if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId + || current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id) + throw providerFailure("slack_install_identity_mismatch"); + if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing"); + if (row.managerGrantId) { + const grant = await managerGrants.get(row.managerGrantId, row.companyId, actor); + if (grant.status !== "active" || installerId !== grant.slackUserId || teamId !== grant.workspaceId) throw providerFailure("slack_install_identity_mismatch"); + } + await endpoint(row.endpointId, actor); + // Reauthorization reuses the runtime signing secret after staging was cleaned. + await saveSecrets(row, { botToken: result.access_token }, actor, lease, + { status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null }, + { externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); + } catch (failure) { + const code = object(object(failure).details).code; + const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed"; + await setFailure(row, row.status, safeCode, lease); + await audit(row, actor, "installation_failed", safeCode); + return row.endpointId; + } + await resumeLocked(row.endpointId, actor, lease); + return row.endpointId; + }); + } + const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease); + async function returnPath(endpointId: string) { + const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints) + .innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId)); + if (!row) throw notFound("Slack connection not found"); + return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`; + } + return { create, managerGrants, managed, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt, + close: store.close, + resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, async lease => { + const row = await registration(endpointId); + if (row?.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + return resumeLocked(endpointId, actor, lease); + }) }; +} diff --git a/server/src/services/connectors/slack/test-message.ts b/server/src/services/connectors/slack/test-message.ts new file mode 100644 index 00000000000..47996f0acf5 --- /dev/null +++ b/server/src/services/connectors/slack/test-message.ts @@ -0,0 +1,134 @@ +import { and, eq, gte, inArray, ne } from "drizzle-orm"; +import { agents, chatActions, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatSlackManagerGrants, companyMemberships, toolConnections, type Db } from "@paperclipai/db"; +import { slackTestMessageSchema, type SlackTestMessageInput, type SlackTestMessageResult } from "@paperclipai/shared"; +import { conflict, forbidden, notFound, tooManyRequests, unprocessable } from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; +import { logActivity } from "../../activity-log.js"; +import { instanceSettingsService } from "../../instance-settings.js"; +import { createSlackSetupClient, object } from "./setup/provider-client.js"; +import type { SlackSetupActor, SlackSetupOptions } from "./setup/registration.js"; + +type Options = Pick & { + ensureAccount: (endpointId: string, user: Record) => Promise; + createLinkIntent: (endpointId: string, principalId: string, expiresInSeconds: number) => Promise<{ confirmationUrl: string; expiresAt: string }>; +}; +const human = (user: Record, workspaceId: string) => + typeof user.id === "string" && /^[UW][A-Z0-9]+$/.test(user.id) && user.team_id === workspaceId && + user.is_bot === false && user.is_app_user !== true && user.deleted !== true && user.id !== "USLACKBOT"; + +export function slackTestMessageService(db: Db, options: Options) { + const api = createSlackSetupClient(options.fetch); + return async (endpointId: string, actor: SlackSetupActor, rawInput: SlackTestMessageInput): Promise => { + const input = slackTestMessageSchema.parse(rawInput); + if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + return options.withLock(endpointId, async lease => { + const [record] = await db.select({ endpoint: chatEndpoints, agentName: agents.name }).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), eq(toolConnections.enabled, true), eq(toolConnections.status, "active"))) + .where(eq(chatEndpoints.id, endpointId)); + if (!record || record.endpoint.provider !== "slack") throw notFound("Active Slack connection not found"); + const endpoint = record.endpoint; + if (!["active", "verifying"].includes(endpoint.status) || !endpoint.providerAccountId || !endpoint.allowDirectMessages) throw conflict("Enable direct messages on an active Slack connection first"); + const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, endpoint.companyId), eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"), + )); + if (!membership) throw forbidden("An active Paperclip account is required"); + const actionKey = `slack_test_message:${actor.userId}:${input.requestId}`; + const [previous] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, actionKey))); + if (previous) { + if (previous.status === "processed" && typeof previous.result?.slackUrl === "string") return { status: "sent", slackUrl: previous.result.slackUrl }; + throw conflict("This message may already have been sent. Check your Slack DMs before sending another."); + } + const recent = await db.select({ id: chatActions.id }).from(chatActions).where(and( + eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), inArray(chatActions.kind, ["slack_test_message", "slack_test_message_invitation"]), gte(chatActions.createdAt, new Date(Date.now() - 60_000)), + )).limit(5); + if (recent.length >= 5) throw tooManyRequests("Wait a minute before sending another test message"); + const links = await db.select({ endpointId: chatIdentityLinks.endpointId, slackUserId: chatExternalPrincipals.externalId }).from(chatIdentityLinks) + .innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, chatIdentityLinks.companyId))) + .where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.paperclipUserId, actor.userId), eq(chatIdentityLinks.status, "linked"), + eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId), eq(chatExternalPrincipals.isBot, false))); + const current = links.filter(link => link.endpointId === endpointId); + const grants = await db.select({ slackUserId: chatSlackManagerGrants.slackUserId }).from(chatSlackManagerGrants).where(and( + eq(chatSlackManagerGrants.companyId, endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.workspaceId, endpoint.providerAccountId), eq(chatSlackManagerGrants.status, "active"), + )); + const known = [...new Set((current.length ? current : [...links, ...grants]).map(link => link.slackUserId))]; + const [invitation] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, `${actionKey}:invite`))); + const invitedUser = typeof invitation?.payload.slackUserId === "string" ? invitation.payload.slackUserId : undefined; + if (invitedUser && input.slackUserId && input.slackUserId !== invitedUser) throw conflict("This request already invited a different Slack account"); + const userId = input.slackUserId ?? invitedUser ?? (known.length === 1 ? known[0] : undefined); + const token = await options.runtimeBotToken(endpointId); + if (!token) throw conflict("Reconnect this Slack app before sending a message"); + async function call(method: string, fields: Record) { + try { return await api(method, fields, token); } + catch { throw unprocessable("Slack could not complete this request. Check the connection and try again."); } + } + if (!userId) { + const accounts: { id: string; name: string }[] = []; + const result = await call("users.list", { limit: "200", ...(input.accountCursor ? { cursor: input.accountCursor } : {}) }); + for (const value of Array.isArray(result.members) ? result.members : []) { + const user = object(value); + if (human(user, endpoint.providerAccountId)) accounts.push({ id: String(user.id), name: String(user.real_name || user.name || user.id) }); + } + const cursor = object(result.response_metadata).next_cursor; + return { status: "connection_required", accounts, nextCursor: typeof cursor === "string" && cursor ? cursor : null }; + } + const user = object((await call("users.info", { user: userId })).user); + if (user.id !== userId || !human(user, endpoint.providerAccountId)) throw unprocessable("Choose your personal Slack account in this workspace"); + if (!known.includes(userId)) { + const principalId = await options.ensureAccount(endpointId, user); + const principalLinks = await db.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.principalId, principalId))); + if (principalLinks.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) + || principalLinks.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link"); + if (invitation) { + if (invitation.status !== "processed") throw conflict("The invitation may already have been sent. Check Slack before requesting another."); + const expiresAt = String(invitation.result?.expiresAt ?? ""); + if (!expiresAt || new Date(expiresAt).getTime() <= Date.now()) throw conflict("The Slack invitation expired. Request a new test message."); + return { status: "connection_pending", principalId, expiresAt }; + } + // The account holder must follow the private Slack challenge and sign in. + // Selecting a workspace member does not prove identity ownership. + const intent = await options.createLinkIntent(endpointId, principalId, 600); + const [pending] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, principalId, + kind: "slack_test_message_invitation", providerActionId: `${actionKey}:invite`, status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning(); + try { + const response = await call("chat.postMessage", { channel: userId, + text: `Connect your Slack account to ${record.agentName} in Paperclip to receive your test message.`, + blocks: JSON.stringify([{ type: "section", text: { type: "plain_text", text: `Connect your Slack account to ${record.agentName} in Paperclip.` } }, + { type: "section", text: { type: "mrkdwn", text: `<${intent.confirmationUrl}|Connect my account> to receive your test message.` } }]), + unfurl_links: "false", unfurl_media: "false" }); + if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm invitation delivery"); + await lease.assertOwned(); + await db.update(chatActions).set({ status: "processed", result: { expiresAt: intent.expiresAt }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id)); + } catch { + await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_invitation_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id)); + throw unprocessable("Invitation delivery was not confirmed. Check Slack before requesting another."); + } + try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_invited", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); } + catch { logger.warn({ endpointId }, "Slack test invitation activity could not be recorded"); } + return { status: "connection_pending", principalId, expiresAt: intent.expiresAt }; + } + await options.linkInstaller(endpointId, user, actor, lease); + await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_requested", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); + await lease.assertOwned(); + // Persist before dispatch: a crash or ambiguous response must never replay the DM. + const [action] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, kind: "slack_test_message", providerActionId: actionKey, + status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning(); + let slackUrl: string; + try { + const response = await call("chat.postMessage", { channel: userId, + text: `Hi, I’m ${record.agentName}. This is your test message. Reply here to talk to me.`, unfurl_links: "false", unfurl_media: "false" }); + if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm delivery. Check your DMs before trying again."); + slackUrl = `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/${response.channel}`; + await lease.assertOwned(); + await db.update(chatActions).set({ status: "processed", result: { slackUrl }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id)); + } catch { + await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_test_message_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id)); + throw unprocessable("Delivery was not confirmed. Check your Slack DMs before sending another test message."); + } + try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_sent", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); } + catch { logger.warn({ endpointId }, "Slack test message activity could not be recorded"); } + return { status: "sent", slackUrl }; + }); + }; +} diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts index 9babca70c11..20f596e01ee 100644 --- a/server/src/services/environment-custom-image-runtime.ts +++ b/server/src/services/environment-custom-image-runtime.ts @@ -13,7 +13,6 @@ import { readConfigValueAtPath, writeConfigValueAtPath } from "./json-schema-sec type TemplateRow = typeof environmentCustomImageTemplates.$inferSelect; export const ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY = "runtimeConfigBinding"; -/** Measured platform evidence is reserved to explicit server-side environment probes. */ export const SANDBOX_PLATFORM_PROBE_METADATA_KEY = "paperclipSandboxPlatformProbes"; export const ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS = [ "timeoutMs", diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts index 8ecad38bc6f..1d6c8ccedc5 100644 --- a/server/src/services/environment-probe.ts +++ b/server/src/services/environment-probe.ts @@ -41,14 +41,12 @@ async function sandboxBinding(db: Db, environment: Environment, companyId: strin }); if (parsed.driver !== "sandbox") return null; const image = parsed.config.image; - // Names, mutable tags, provider defaults and snapshot names are not immutable identities. const template = selectedTemplate.value; if (!isImmutableImage(image) || ["snapshot", "template", "templateRef"].some(key => record(parsed.config)[key]) || (template && (template.templateKind !== "image" || template.bindingField !== "image"))) return null; return { baseConfigFingerprint: fingerprint(environment.config), runtimeConfigFingerprint: fingerprint(parsed.config as unknown as Record), image, template }; } -/** Read measured, company-scoped image evidence without leasing or contacting a provider. */ export async function readSandboxPlatformQualification(db: Db, environment: Environment, companyId: string): Promise { const proof = record(record(environment.metadata?.[SANDBOX_PLATFORM_PROBE_METADATA_KEY])[companyId]); if (proof.v !== 1 || proof.companyId !== companyId || proof.baseConfigFingerprint !== fingerprint(environment.config)) return null; diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 7bf26c9d419..b8922aa61d7 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -1144,8 +1144,6 @@ export function environmentService(db: Db) { values.metadata = userMetadata; } else { const replacement = userMetadata === null ? sql`null::jsonb` : sql`${JSON.stringify(userMetadata)}::jsonb`; - // Preserve the current private subtree in the same statement. A route's - // earlier read must never replace a newer concurrent probe receipt. metadataUpdate = sql`case when ${environments.metadata} ? ${SANDBOX_PLATFORM_PROBE_METADATA_KEY} then coalesce(${replacement}, '{}'::jsonb) || jsonb_build_object( ${SANDBOX_PLATFORM_PROBE_METADATA_KEY}::text, ${environments.metadata}->${SANDBOX_PLATFORM_PROBE_METADATA_KEY}) diff --git a/server/src/services/heartbeat/run-control.test.ts b/server/src/services/heartbeat/run-control.test.ts index 0234f326f48..d140f7f234d 100644 --- a/server/src/services/heartbeat/run-control.test.ts +++ b/server/src/services/heartbeat/run-control.test.ts @@ -5,7 +5,15 @@ import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { runningProcesses } from "../../adapters/index.js"; -import { adapterExecutionControls } from "../adapter-execution-control.js"; +import { + adapterExecutionControls, + createAdapterExecutionControl, + reportAdapterStopIfUnsettled, + waitForAdapterStop, +} from "../adapter-execution-control.js"; +import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js"; +import { HttpError } from "../../errors.js"; +import { captureException } from "../../sentry.js"; import { terminateLocalService } from "../local-service-supervisor.js"; import { createHeartbeatRunState } from "./run-state.js"; import { createHeartbeatRunPreparation } from "./run-preparation.js"; @@ -13,6 +21,14 @@ import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from ". import { createHeartbeatRunControl, type HeartbeatRunControlDependencies } from "./run-control.js"; vi.mock("../live-events.js", () => ({ publishLiveEvent: vi.fn() })); +vi.mock("../adapter-execution-control.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + waitForAdapterStop: vi.fn(actual.waitForAdapterStop), + reportAdapterStopIfUnsettled: vi.fn(actual.reportAdapterStopIfUnsettled), + }; +}); vi.mock("../local-service-supervisor.js", async (importOriginal) => ({ ...await importOriginal(), terminateLocalService: vi.fn(async () => undefined), @@ -256,6 +272,67 @@ describe.skipIf(!support.supported)("heartbeat run-control database wiring", () expect(deps.failedProcessRunCancellations.get(run.id)?.error).toBe(failure); }); + async function ownedAdapterRun() { + const { company, agent } = await fixture(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running", runtimeMode: "legacy", runtimeModeResolvedAt: new Date() }).returning(); + const control = createAdapterExecutionControl(); + adapterExecutionControls.set(run.id, control); + return { run, control }; + } + + it("answers an owned adapter Stop that is still settling with a pending conflict", async () => { + const { run, control } = await ownedAdapterRun(); + const deps = callbacks(db); + deps.activeRunExecutions.add(run.id); + try { + let waitStartedAt = 0; + vi.mocked(waitForAdapterStop).mockImplementationOnce(async () => { + waitStartedAt = Date.now(); + throw new AdapterStopTimeoutError(60_000, { runId: run.id }); + }); + const requestedAt = Date.now(); + const error = await createHeartbeatRunControl(db, deps).cancelRunInternal(run.id).catch((err: unknown) => err); + expect(error).toBeInstanceOf(HttpError); + expect(error).toMatchObject({ + status: 409, + message: "Execution is still stopping; termination has not been verified.", + details: { code: "adapter_stop_pending", runId: run.id }, + }); + expect(control.controller.signal.aborted).toBe(true); + // Only a Stop that never settles is reported, from the same execution. + expect(reportAdapterStopIfUnsettled).toHaveBeenCalledWith(control.settled, captureException, + expect.objectContaining({ runId: run.id, adapterType: "codex_local", runtimeMode: "legacy", abortRequested: true }), control, + { stopRequestedAt: expect.any(Number) }); + // Its report bound runs from the Stop request, before the response wait. + const { stopRequestedAt } = vi.mocked(reportAdapterStopIfUnsettled).mock.calls.at(-1)![4]!; + expect(stopRequestedAt).toBeGreaterThanOrEqual(requestedAt); + expect(stopRequestedAt).toBeLessThanOrEqual(waitStartedAt); + // A pending Stop is not a cancellation receipt. + expect(deps.setRunStatusFromLive).not.toHaveBeenCalled(); + expect(deps.finalizeAgentStatus).not.toHaveBeenCalled(); + expect((await deps.getRun(run.id))?.status).toBe("running"); + expect(deps.failedProcessRunCancellations.has(run.id)).toBe(false); + } finally { + control.finish(); + adapterExecutionControls.delete(run.id); + } + }); + + it("keeps other owned adapter Stop failures unchanged", async () => { + const { run, control } = await ownedAdapterRun(); + const deps = callbacks(db); + vi.mocked(reportAdapterStopIfUnsettled).mockClear(); + try { + const failure = new Error("owner wait failed"); + vi.mocked(waitForAdapterStop).mockRejectedValueOnce(failure); + await expect(createHeartbeatRunControl(db, deps).cancelRunInternal(run.id)).rejects.toBe(failure); + expect(reportAdapterStopIfUnsettled).not.toHaveBeenCalled(); + } finally { + control.finish(); + adapterExecutionControls.delete(run.id); + } + }); + it("deduplicates agent batches and cancels only unclaimed pending wakes", async () => { const { company, agent } = await fixture(); const [otherAgent] = await db.insert(agents).values({ companyId: company.id, name: "Other agent" }).returning(); diff --git a/server/src/services/heartbeat/run-control.ts b/server/src/services/heartbeat/run-control.ts index f744f35d147..f4b0529ddc9 100644 --- a/server/src/services/heartbeat/run-control.ts +++ b/server/src/services/heartbeat/run-control.ts @@ -44,8 +44,11 @@ import { import { adapterExecutionControls, captureAdapterStopOwnership, + reportAdapterStopIfUnsettled, waitForAdapterStop, } from "../adapter-execution-control.js"; +import { AdapterStopTimeoutError } from "../adapter-stop-timeout.js"; +import { captureException } from "../../sentry.js"; import { randomUUID } from "node:crypto"; import { and, @@ -999,6 +1002,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont processCancellationSettlement, ); } + const stopRequestedAt = Date.now(); const cancellation = await (async () => { try { if (control) { @@ -1059,12 +1063,24 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont } if (control) { - await waitForAdapterStop(control.settled, undefined, { + const stopDiagnostics = { runId: run.id, adapterType: agent?.adapterType, runtimeMode: run.runtimeMode, abortRequested: control.controller.signal.aborted, - }, control); + }; + try { + await waitForAdapterStop(control.settled, undefined, stopDiagnostics, control); + } catch (error) { + if (!(error instanceof AdapterStopTimeoutError)) throw error; + // The owned adapter is still settling (for example copying the + // workspace back) and keeps its Stop proof requirements. Answer + // this request honestly; report only a Stop that never settles. + reportAdapterStopIfUnsettled(control.settled, captureException, stopDiagnostics, control, { + stopRequestedAt, + }); + throw conflict(error.message, { code: "adapter_stop_pending", runId: run.id }); + } const stopped = await getRun(run.id); if (stopped && isHeartbeatRunTerminalStatus(stopped.status)) { if ( diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index 505b0bee18c..5f021570189 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -45,8 +45,6 @@ function targetPlatform(platform: string, architecture: string, glibc = true, gl } function resolveFixtureCodexCommand(home: string): string { - // Vitest's global dependency paths can supply a foreign bridge before the - // fixture wrapper. Reuse the existing Codex security test's closed Node seam. const source = ` import { resolveCodexCommand } from ${JSON.stringify(new URL("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts", import.meta.url).href)}; try { console.log(JSON.stringify({ command: resolveCodexCommand(${JSON.stringify(path.join(root!, "issuer.js"))}, { PATH: "/missing-codex" }, ${JSON.stringify(home)}) })); } diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index 755a1e41573..4ae7e26ab16 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -56,8 +56,6 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> { let executable = "claude"; if (provider === "openai") { - // The installed Codex CLI is independent of the runner daemon's glibc floor. - // Supported CLI hosts use owned resolution; other platforms keep PATH lookup. const legacyPlatform = !((process.platform === "linux" || process.platform === "darwin") && (process.arch === "x64" || process.arch === "arm64")); try { diff --git a/server/src/services/native-runtime/native-codex-runner.ts b/server/src/services/native-runtime/native-codex-runner.ts index c5cda8a9942..f8e22050f29 100644 --- a/server/src/services/native-runtime/native-codex-runner.ts +++ b/server/src/services/native-runtime/native-codex-runner.ts @@ -208,7 +208,6 @@ export function resolvePaperclipRunnerBinary( ); } -/** Select controller-owned bytes for the actual remote host, never its controller. */ export async function resolveRemotePaperclipRunnerBinary(input: { runner: CommandManagedRuntimeRunner; remoteCwd: string; @@ -224,8 +223,6 @@ export async function resolveRemotePaperclipRunnerBinary(input: { } const configured = input.configuredPath?.trim(); if (configured && !isAbsolute(configured)) throw new Error("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH must be an absolute path"); - // A same-host sandbox can use the supervisor's fresh development artifact. - // Cross-platform targets must never inherit the controller's override. const controllerOverride = platform === process.platform && architecture === process.arch ? process.env.PAPERCLIP_RUNNER_BINARY : undefined; const binary = configured || (controllerOverride diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 04a84233639..97c237d6000 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -12392,8 +12392,6 @@ describe("runnerd provider runtime wiring", () => { { version: "0.156.0", model: "gpt-6.1-sol", floor: null, recover: true }, { version: "0.158.0", model: "gpt-6.1-sol", floor: null, recover: true }, ])("lets sandbox Codex $version reach actual model verification ($model, recover=$recover)", async ({ version, model, recover }) => { - // Version metadata is diagnostic. A working app-server reaches the model - // turn instead of being rejected or replaced by a static CLI version floor. const preparedModel = recover ? resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: { provider: "codex", model }, codexCliVersion: version, }).model : model; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index fb10467955d..89aa432abe8 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -13407,7 +13407,6 @@ async function createRunnerdBackendWithinSessionClaim( } satisfies NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession }; } -/** Shared task/setup preparation; setup exercises the same remote artifact contract. */ export function createRemoteNativeArtifactPreparation(input: { target: Extract; runner: CommandManagedRuntimeRunner; @@ -13420,7 +13419,6 @@ export function createRemoteNativeArtifactPreparation(input: { runnerRemoteCodexNpmSpec?: string | null; model?: string | null; reuseRetainedRunner?: boolean; - /** The selected Pi companion or bundled provider image verifies its target-platform artifact. */ targetPlatformArtifactVerified?: boolean; trace?: NativeRunTrace; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; @@ -13473,9 +13471,6 @@ export function createRemoteNativeArtifactPreparation(input: { } const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`; const version = parseCodexCliVersion(versionOutput); - // The install pin describes our tested image, not a CLI admission rule. - // Ordinary installations reach the real app-server/model probe; a version - // difference alone must neither fail setup nor replace a working CLI. if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) { reportedCodexVersions.add(version); await input.onLog?.( @@ -13485,9 +13480,6 @@ export function createRemoteNativeArtifactPreparation(input: { } }; - // Image policy: keep one latest stable CLI installation shared by native and - // local adapters. Preferred bin entries must point to that same installation; - // never bake an older global CLI alongside a private runner-only version. const discoverPreinstalledExecutable = async ( name: "paperclip-runnerd" | "codex", ) => { @@ -13522,8 +13514,6 @@ export function createRemoteNativeArtifactPreparation(input: { }; const prepare = async (requiredMode: "dial_wss" | "listen_ws") => { - // Compatibility metadata does not prove artifact identity. Reuse only the - // exact controller-owned bytes when the controller artifact is available. const matchesControllerRunnerArtifact = async (executable: string): Promise => { const expected = createHash("sha256") .update(readFileSync(controllerRunnerBinary)) @@ -13545,7 +13535,6 @@ export function createRemoteNativeArtifactPreparation(input: { /^[a-f0-9]{64}\s/.test(probe.stdout) && probe.stdout.trim().split(/\s+/)[0] === expected; } catch { - // An unavailable checksum uses the verified staging path. return false; } }; @@ -13594,9 +13583,6 @@ export function createRemoteNativeArtifactPreparation(input: { } } if (!runnerArtifactPrepared) { - // Upload the same artifact used for the controller identity. The server - // vendors the runner under vendor/paperclip-runner/bin, so the package - // development fallback cannot locate it in a deployed server. const sourceBinary = controllerRunnerBinary; if (!existsSync(sourceBinary)) { throw new Error("runner_remote_artifact_unavailable"); diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 750cb8802cf..f13a246508c 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -167,7 +167,6 @@ export function validatePaperclipRunnerDotConfig(config: Record return bindingId; } -/** A task may change a local Runner model and supported effort, but not provider identity. */ export function projectPaperclipRunnerTaskConfig( backend: "codex_app_server" | "opencode_server" | "acpx_runtime", agentConfig: unknown, diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 42e8a3cd4bb..a2a0b37abe0 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -7,6 +7,7 @@ import { randomBytes, type KeyObject, } from "node:crypto"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; import { describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; @@ -406,6 +407,16 @@ describe("Paperclip Cloud connector", () => { })).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" }); }); + it("opens Slack manager handoffs with their exact profile and personal identity", async () => { + const keys = config(); + const credentials = { v: 1, accessToken: "manager-access-canary", refreshToken: "manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: "2030-01-01T00:00:00.000Z", refreshTokenExpiresAt: null, + scopes: [...SLACK_MANAGER_SCOPES], subject, companyId, instanceId, environment: "staging", provider: "slack", profile: "slack.manager", + slackManager: { appId: "AMANAGER", workspaceId: "TWORKSPACE", workspaceName: "Paperclip", userId: "UOWNER" } }; + const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "slack.manager"); + const connector = createPaperclipCloudConnector({ config: keys.config, request: vi.fn(async () => Response.json({ claimId: "claim", scopes: [...SLACK_MANAGER_SCOPES], sealed })) as typeof fetch }); + await expect(connector.claim({ subject, companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).resolves.toEqual(credentials); + await expect(connector.claim({ subject: "another-user", companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).rejects.toThrow(); + }); it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => { const keys = config(); const credentials = { @@ -612,10 +623,10 @@ function seal( recipientPublicKey: KeyObject, purpose: "initial" | "access" | "events", configValue: PaperclipCloudConnectorConfig, - profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "github.bot" | "github.code", + profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "slack.manager" | "github.bot" | "github.code", ) { - const provider = profile.startsWith("github.") ? "github" : "google"; - const scopes = provider === "github" ? [] : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes; + const provider = profile.startsWith("github.") ? "github" : profile === "slack.manager" ? "slack" : "google"; + const scopes = provider === "github" ? [] : profile === "slack.manager" ? SLACK_MANAGER_SCOPES : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes; const ephemeral = generateKeyPairSync("x25519"); const ephemeralJwk = ephemeral.publicKey.export({ format: "jwk" }) as { x: string }; const recipientJwk = recipientPublicKey.export({ format: "jwk" }) as { x: string }; diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 7785deeb596..241ff937b57 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -1,4 +1,4 @@ -import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; +import { SLACK_MANAGER_SCOPES, ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { createDecipheriv, createHash, @@ -34,8 +34,8 @@ export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES }; export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production"; export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app"; -export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "github.bot"; -export type PaperclipCloudConnectorProvider = "google" | "github" | "asana"; +export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "slack.manager" | "github.bot"; +export type PaperclipCloudConnectorProvider = "google" | "github" | "asana" | "slack"; export type PaperclipCloudConnectorConfig = { baseUrl: string; @@ -60,6 +60,7 @@ export type SealedConnectorCredentials = { provider: PaperclipCloudConnectorProvider; profile: string; appSlug?: string; + slackManager?: { appId: string; workspaceId: string; workspaceName: string; userId: string }; }; export type SealedGmailCredentials = SealedConnectorCredentials & { provider: "google" }; @@ -95,6 +96,7 @@ type SealedEnvelope = { }; type ConnectorResponse = { + slackManager?: unknown; confirmationUrl?: unknown; authorizationUrl?: unknown; handoff?: unknown; @@ -413,6 +415,13 @@ export function createPaperclipCloudConnector(input: { typeof value === "string" && isPaperclipCloudConnectorProfileId(value) ? [value] : [] ))]; }, + async getSlackManagerAppId(): Promise { + const response = await call("status", { subject: "instance-capabilities", companyId: "instance-capabilities" }); + const manager = response.slackManager; + if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles) || !response.profiles.includes("slack.manager") + || !manager || typeof manager !== "object" || !("appId" in manager) || typeof manager.appId !== "string" || !/^A[A-Z0-9]+$/.test(manager.appId)) return null; + return manager.appId; + }, async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) { const profile = values.profile ?? "gmail.draft"; const response = await call("session", { ...values, profile }); @@ -782,6 +791,7 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): provider: PaperclipCloudConnectorProvider; scopes: readonly string[]; } { + if (profile === "slack.manager") return { provider: "slack", scopes: SLACK_MANAGER_SCOPES }; if (profile === "github.bot") return { provider: "github", scopes: [] }; if (isAsanaConnectorProfileId(profile)) { return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES }; @@ -796,6 +806,7 @@ function isExpectedProviderAuthorizationUrl( profile: PaperclipCloudConnectorProfileId, url: URL, ): boolean { + if (profile === "slack.manager") return url.origin === "https://slack.com" && url.pathname === "/oauth/v2/authorize"; if (isAsanaConnectorProfileId(profile)) { return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize"; } @@ -806,7 +817,7 @@ function isExpectedProviderAuthorizationUrl( } function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId { - return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value); + return value === "slack.manager" || isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value); } async function sha256Base64Url(value: string): Promise { diff --git a/server/src/services/run-failure-report.ts b/server/src/services/run-failure-report.ts index a059bf523d0..60e6309e541 100644 --- a/server/src/services/run-failure-report.ts +++ b/server/src/services/run-failure-report.ts @@ -164,6 +164,16 @@ function isHermesConnectionFailure(run: HeartbeatRun, options: RunFailureReportO (connection.reason === "remote_unavailable" && code === "hermes_gateway_upstream_error"); } +/** + * The adapter proved the user's provider account is out of quota, and recovery + * waits for its reset. Both fields come from the adapter's own classifier. + */ +function isProviderQuotaFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean { + return run.status === "failed" && run.errorCode === "provider_quota" && + asRecord(run.resultJson)?.errorFamily === "provider_quota" && + options.phase !== "setup" && run.signal == null; +} + /** * Report a terminal run failure to Sentry. Returns at once for any status * other than failures and unexpected started cancellations. Never throws — a Sentry failure or a @@ -182,6 +192,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR if (isAiConnectionSelectionBlocker(run, options)) return Promise.resolve(); if (isLocalPathWorkspaceConfigurationBlocker(run, options)) return Promise.resolve(); if (isGitConnectionFailure(run, options)) return Promise.resolve(); + if (isProviderQuotaFailure(run, options)) return Promise.resolve(); const runStatus = run.status; const report = captureTerminalRunFailure(db, run, runStatus, options); pendingRunFailureReports.add(report); diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index c64cb950ca8..a7795120a94 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -156,6 +156,114 @@ test.describe.serial("native chat adapter UI", () => { seed = await seedCompanyAndAgent(request); }); + test("Slack: managed workspace setup has two stops, resumes and completes without pasting credentials", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`); + await selectMaya(page); + await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + await expect(page.getByLabel("App configuration access token", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("Signing Secret", { exact: true })).toHaveCount(0); + await page.reload(); + expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(1); + mock.setWebhookVerified(); + await expect(page.getByRole("heading", { name: "Success", exact: true })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); + }); + test("Slack: a failed manager refresh reloads workspace authorization before retry", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + let refreshFailed = false; + let authorizationRequests = 0; + await page.route("**/chat-slack/setup-options", async route => { + if (!refreshFailed) return route.fallback(); + await route.fulfill({ json: { managedAvailable: true, defaultMethod: "managed", workspaces: [] } }); + }); + await page.route("**/slack/managed/provision", async route => { + refreshFailed = true; + await route.fulfill({ status: 422, json: { error: "Reconnect your Slack workspace.", details: { code: "slack_manager_reauthorize" } } }); + }); + await page.route("**/slack/managed/authorize", async route => { + authorizationRequests++; + await route.fulfill({ json: { authorizationUrl: "https://slack.test/manager-consent", expiresAt: new Date(Date.now() + 600_000).toISOString() } }); + }); + await page.route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" })); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + await expect(page.getByRole("alert")).toBeVisible(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await expect.poll(() => authorizationRequests).toBe(1); + await expect(page).toHaveURL("https://slack.test/manager-consent"); + }); + test("Slack: every uncertain creation retry needs a fresh confirmation", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + mock.setSlackUncertain(); + const attempts: { requestId: string; confirmedNoAppCreated?: boolean }[] = []; + await page.route("**/slack/managed/provision", async route => { + attempts.push(route.request().postDataJSON()); + await route.fulfill({ json: {} }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + const confirm = page.getByRole("checkbox", { name: "I checked Slack and no app was created. Create a new app." }); + const create = page.getByRole("button", { name: "Continue", exact: true }); + await expect(create).toBeDisabled(); + await confirm.check(); await create.click(); + await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled(); + expect(attempts).toHaveLength(1); expect(attempts[0]!.confirmedNoAppCreated).toBe(true); + await confirm.check(); await create.click(); + await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled(); + expect(attempts).toHaveLength(2); expect(attempts[1]!.requestId).not.toBe(attempts[0]!.requestId); + }); + test("Slack: own-app creation consumes confirmation even when the response is lost", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true }); + mock.setSlackUncertain(); + let attempts = 0; + await page.route("**/slack/registration", async route => { + attempts++; expect(route.request().postDataJSON().confirmedNoAppCreated).toBe(true); + await route.fulfill({ status: 502, json: { error: "Creation response unavailable" } }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + const confirm = page.getByRole("checkbox"); + await confirm.check(); + await page.getByLabel("App configuration access token", { exact: true }).fill("fixture-config-token"); + await page.getByRole("button", { name: "Create Slack app", exact: true }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + await expect(confirm).not.toBeChecked(); + await expect(page.getByRole("button", { name: "Create Slack app", exact: true })).toBeDisabled(); + expect(attempts).toBe(1); + }); + test("Slack: temporarily unavailable managed setup can retry in place", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + let available = false; + await page.route("**/chat-slack/setup-options", async route => { + if (available) return route.fallback(); + await route.fulfill({ json: { managedAvailable: false, defaultMethod: "automatic", workspaces: [] } }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + await expect(page.getByText("Managed Slack setup is unavailable. Try again later.")).toBeVisible(); + available = true; await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + }); + test("Slack: own-app selection persists before provisioning", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`); + await page.getByRole("button", { name: "Choose an active agent" }).click(); + await page.getByRole("button", { name: "Select Maya" }).click(); + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Use your own app" }).click(); + await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible(); + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Use managed setup" }).click(); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + }); test("Slack: automatic creation survives refresh, consent, and connection evidence without copying durable secrets", async ({ page }) => { const slack = PROVIDERS.find(provider => provider.provider === "slack")!; const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true }); @@ -440,6 +548,10 @@ test.describe.serial("native chat adapter UI", () => { ).toBeVisible(); await expectSetupRail(page, true); await selectMaya(page); + if (provider.provider === "slack") { + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Create manually", exact: true }).click(); + } await expect.poll(() => mock.createdWithAgentId).toBe(seed.agentId); expect(mock.createdWithAgentId).not.toBe(seed.otherAgentId); await expect( @@ -573,18 +685,12 @@ test.describe.serial("native chat adapter UI", () => { page.getByRole("heading", { name: provider.provider === "slack" ? "Allowed Channels" : "Destinations" }), ).toBeVisible(); if (provider.provider === "slack") { - await page.locator("summary").filter({ hasText: "Message in a channel" }).click(); - await expect(page.getByText("@maya-paperclip you there?", { exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy message" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Allowed Channels" })).toBeVisible(); - await page.locator("summary").filter({ hasText: /^Agent avatar$/ }).click(); - const avatarSection = page.getByRole("region", { name: "Slack avatar" }); - await expect(avatarSection.getByRole("link", { name: "Download avatar" })).toBeVisible(); - await avatarSection.getByText("How to upload in Slack", { exact: true }).click(); - await expect(avatarSection.getByRole("link", { name: "Open Slack app Settings" })).toBeVisible(); - const settingsDownloadEvent = page.waitForEvent("download"); - await avatarSection.getByRole("link", { name: "Download avatar" }).click(); - expect((await settingsDownloadEvent).suggestedFilename()).toBe("maya-paperclip-avatar.png"); + await expect(page.getByRole("button", { name: "Send me a test message" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Open Slack configuration" })).toBeVisible(); + await expect(page.getByText("Message in a channel", { exact: true })).toHaveCount(0); + await expect(page.getByText("Agent avatar", { exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "View message" })).toHaveCount(0); } await expect( diff --git a/tests/e2e/chat-adapters-ui.shared.ts b/tests/e2e/chat-adapters-ui.shared.ts index e8904fc98ed..afa8a9686b9 100644 --- a/tests/e2e/chat-adapters-ui.shared.ts +++ b/tests/e2e/chat-adapters-ui.shared.ts @@ -281,7 +281,7 @@ export function endpointFixture(provider: ProviderCase, seed: Seed) { proactiveDirectMessages: false, }, setup: { - slackSetupMethod: undefined as "automatic" | "manual" | "existing" | undefined, + slackSetupMethod: undefined as "managed" | "automatic" | "manual" | "existing" | undefined, slackRegistration: undefined as SlackRegistrationState | undefined, slackApp: undefined as SlackAppConfiguration | undefined, slackOAuthCallbackUri: "https://paperclip.example.test/api/chat-slack/oauth/callback", @@ -353,10 +353,11 @@ export async function installChatControlPlaneMock( resourceCount = 2, photonShared = false, automaticSlack = false, - }: { enableChatConnectors: boolean; enableGitHubReviewBots?: boolean; resourceCount?: number; photonShared?: boolean; automaticSlack?: boolean }, + managedSlack = false, + }: { enableChatConnectors: boolean; enableGitHubReviewBots?: boolean; resourceCount?: number; photonShared?: boolean; automaticSlack?: boolean; managedSlack?: boolean }, ): Promise { const endpoint = endpointFixture(provider, seed); - if (automaticSlack) endpoint.setup.slackSetupMethod = "automatic"; + if (automaticSlack || managedSlack) endpoint.setup.slackSetupMethod = managedSlack ? "managed" : "automatic"; let slackIdentityLinked = false; let githubAppConnected = false; let githubConfiguration = { revision: 0, configuration: { @@ -510,6 +511,7 @@ export async function installChatControlPlaneMock( assignedAgentId: seed.agentId, }); state.createdWithAgentId = String(body.assignedAgentId); + if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "managed" | "automatic" | "manual" | "existing"; if (provider.provider === "slack" && body.slackApp) { endpoint.setup.slackApp = body.slackApp as SlackAppConfiguration; endpoint.setup.command = endpoint.setup.slackApp.command; @@ -536,7 +538,7 @@ export async function installChatControlPlaneMock( if (method === "PATCH") { const body = bodyOf(route); if (body.slackApp) endpoint.setup.slackApp = body.slackApp as SlackAppConfiguration; - if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "automatic" | "manual" | "existing"; + if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "managed" | "automatic" | "manual" | "existing"; if (typeof body.allowDirectMessages === "boolean") state.allowDirectMessages = body.allowDirectMessages; if (typeof body.allowGroupChats === "boolean") @@ -563,6 +565,16 @@ export async function installChatControlPlaneMock( return; } + if (pathname === `/api/companies/${seed.companyId}/chat-slack/setup-options`) { + await fulfill(route, { managedAvailable: managedSlack, defaultMethod: managedSlack ? "managed" : "automatic", workspaces: managedSlack ? [{ grantId: "11111111-1111-4111-8111-111111111111", workspaceId: "TE2E", workspaceName: "Paperclip", userId: "UINSTALLER" }] : [] }); return; + } + if (pathname === `/api/chat-endpoints/${endpoint.id}/slack/managed/provision`) { + const body = bodyOf(route); + expect(body.grantId).toBe("11111111-1111-4111-8111-111111111111"); + expect(body).not.toHaveProperty("credentials"); + state.slackCreations++; state.slackInstallations++; + state.setSlackInstalled(); await fulfill(route, {}); return; + } if (pathname === `/api/chat-endpoints/${endpoint.id}/slack/registration`) { const body = bodyOf(route); expect(body.credentials).toEqual({ configurationToken: "fixture-config-token" }); diff --git a/tests/runner-e2e/installed-cli.test.ts b/tests/runner-e2e/installed-cli.test.ts index e902e66a67d..d14b65d8df0 100644 --- a/tests/runner-e2e/installed-cli.test.ts +++ b/tests/runner-e2e/installed-cli.test.ts @@ -10,8 +10,6 @@ import { buildPaperclipServerEnvironment, buildRunnerE2EProcessEnvironment } fro const cells = runnerMatrix.filter(e => e.profile.id === "runner-acpx-pi"); const roots: string[] = []; beforeEach(() => { - // pnpm's test shim can inject NODE_PATH. Installed-startup fixtures model - // the production launcher's clean environment, not that test shim. vi.stubEnv("NODE_PATH", undefined); vi.stubEnv("NODE_OPTIONS", undefined); }); diff --git a/tests/runner-e2e/installed-cli.ts b/tests/runner-e2e/installed-cli.ts index 6bcfc6f005d..a7b84db3b0c 100644 --- a/tests/runner-e2e/installed-cli.ts +++ b/tests/runner-e2e/installed-cli.ts @@ -12,8 +12,6 @@ import { CREDENTIAL_NAMES, type MatrixExecution } from "./types.js"; export const installedCliKeys = ["PAPERCLIP_RUNNER_E2E_INSTALLED_CLI", "PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256"] as const; const overrideKeys = ["PAPERCLIP_RUNNER_BINARY", "PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "PAPERCLIP_RUNNER_ACPX_QUALIFICATION", "PAPERCLIP_ACPX_BUILTIN_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"] as const; const suites = new Set(["extended-harnesses", "pi-native", "pi-controls", "rich-acp-warm-continuity"]); -// Reuse the production-onboarding, independent file/revision, and restart -// journeys. Installed proof does not add cases or relax their existing graders. const installedCodexCells = new Set([ "first-task.runner-codex.local.task-card-accept", "everyday-workflows.runner-codex.local.build-revise", @@ -63,7 +61,6 @@ export async function verifyInstalledCli(env: NodeJS.ProcessEnv, executions: rea return { schema: "paperclip.e2e.installed-cli-admission/v1", entry, cliRoot, cliSha256, serverRoot, serverEntry, serverSha256, version: cli.version, defaultRuntimeResolution: true, qualificationOverride: false }; } -/** Clean-install protocol proof only: no credentials, task, or provider turn. */ export async function probeInstalledCodexStartup(input: { consumerRoot: string; serverRoot: string; @@ -110,8 +107,6 @@ export async function probeInstalledCodexStartup(input: { const codexSha256 = digest(await readOwned(codexCommand, 512 * 1024 * 1024)); const codexVersion = execFileSync(codexCommand, ["--version"], { cwd: workspace, env: environment, encoding: "utf8", timeout: 10000, maxBuffer: 16384 }).trim(); if (!codexVersion || codexVersion.length > 200) throw new Error("Installed Codex startup returned no bounded version identity"); - // Keep the production security arguments; pin file-only auth for this - // credential-free fixture so macOS keychain accounts cannot be consulted. const args = runtime.createIsolatedCodexAppServerArgs(environment); args.splice(args.indexOf("app-server"), 0, "-c", 'cli_auth_credentials_store="file"'); bundle = runtime.createRunnerdCodexTransport({ provider: "codex", stateDirectory: directory, environment, @@ -156,9 +151,6 @@ export async function probeInstalledCodexStartup(input: { } catch (error) { cleanupFailures.push(error); } if (startupFailed) { const evidence = bundle?.evidence(); - // This zero-turn fixture has empty homes and no provider credentials. - // Report only bounded failure text and lifecycle state, never identities - // or raw durable state. A close failure cannot hide the startup cause. const bounded = (value: unknown) => String(value).replaceAll(directory, "[private-startup]").slice(0, 2000); const primaryMessage = bounded(startupFailure instanceof Error ? startupFailure.message : startupFailure); const diagnostic = { schema: "paperclip.e2e.installed-codex-startup-failure/v1", platform: `${process.platform}-${process.arch}`, diff --git a/tests/runner-e2e/live-fixtures.ts b/tests/runner-e2e/live-fixtures.ts index 0faf1a9d58a..e236bf8ac83 100644 --- a/tests/runner-e2e/live-fixtures.ts +++ b/tests/runner-e2e/live-fixtures.ts @@ -64,7 +64,6 @@ export interface LiveFixtureValues { agent: AgentRecord; project?: ProjectRecord; aiConnection?: ManagedAccountFixture; - /** Private setup receipt; its lease is included in existing runtime cost accounting. */ setupProbe?: { leaseId: string; durationMs: number; defaultRunner?: string }; onboardingRuntime?: { @@ -82,7 +81,6 @@ function value(resolved: ReadonlyMap, id: string): T { return result as T; } -/** Preserve task/run attribution and include only this fixture's explicit setup probe. */ export function selectFixtureRuntimeLeases(input: { leases: T[]; runIds: string[]; issueId?: string; setupProbeLeaseId?: string }): T[] { @@ -320,8 +318,6 @@ export async function setupLiveFixtures(input: { throw new Error("Native Codex qualification requires this fixture's immutable image-backed environment."); } const startedAt = Date.now(); - // One existing explicit connection probe: a fresh lease, bounded platform read, - // verified release, and server-owned image evidence before agent creation. const probe = await api.post<{ ok: boolean; details?: { platformQualificationRecorded?: boolean; leaseId?: string } }>( `/api/environments/${environment.id}/probe?companyId=${encodeURIComponent(company.id)}`, {}, { timeout: 330_000 }, diff --git a/ui/src/api/chatEndpoints.ts b/ui/src/api/chatEndpoints.ts index a640a8e3f98..73aa8080ef4 100644 --- a/ui/src/api/chatEndpoints.ts +++ b/ui/src/api/chatEndpoints.ts @@ -1,3 +1,4 @@ +import type { SlackSetupOptions, SlackManagedProvisionInput, SlackManagedResult } from "@paperclipai/shared"; import { api } from "./client"; import type { SlackAppConfiguration, @@ -140,7 +141,8 @@ export interface ChatEndpoint { messagingEndpoint?: string | null; command?: string | null; slackApp?: SlackAppConfiguration; - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; + slackManagerError?: string; slackRegistration?: SlackRegistrationState; slackAvatar?: SlackAvatarState; slackAccount?: SlackAccountState; @@ -202,7 +204,7 @@ export const chatEndpointsApi = { api.get(`/chat-endpoints/${endpointId}`), create: ( companyId: string, - input: { provider: ChatProvider; assignedAgentId: string; slackApp?: SlackAppConfiguration }, + input: { provider: ChatProvider; assignedAgentId: string; slackApp?: SlackAppConfiguration; slackSetupMethod?: "managed" | "automatic" | "manual" | "existing" }, ) => api.post(`/companies/${companyId}/chat-endpoints`, input), update: ( endpointId: string, @@ -216,6 +218,11 @@ export const chatEndpointsApi = { photon?: PhotonChannelConfiguration; }, ) => api.post(`/chat-endpoints/${endpointId}/setup`, input), + sendSlackTestMessage: (endpointId: string, input: import("@paperclipai/shared").SlackTestMessageInput) => + api.post(`/chat-endpoints/${endpointId}/slack/test-message`, input), + slackSetupOptions: (companyId: string) => api.get(`/companies/${companyId}/chat-slack/setup-options`), + authorizeSlackManager: (endpointId: string) => api.post(`/chat-endpoints/${endpointId}/slack/managed/authorize`, {}), + provisionManagedSlack: (endpointId: string, input: SlackManagedProvisionInput) => api.post(`/chat-endpoints/${endpointId}/slack/managed/provision`, input), createSlackApp: (endpointId: string, input: SlackRegistrationInput) => api.post(`/chat-endpoints/${endpointId}/slack/registration`, input), installSlackApp: (endpointId: string) => diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts index 0cac5237006..59bf09466f7 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -3,7 +3,7 @@ import { createTenantSessionRecoveryCoordinator, tenantSessionRecovery, } from "@/lib/tenant-session-recovery"; -import { __inflightGetCount, api, detachInflightGet } from "./client"; +import { api, detachInflightGet } from "./client"; import { toolsApi } from "./tools"; interface Deferred { @@ -93,6 +93,10 @@ describe("tenant-session recovery", () => { status: 401, message: "unauthorized", }); + + fetchMock.mockResolvedValue(jsonResponse({ recovered: true })); + await expect(api.get("/ordinary-401")).resolves.toEqual({ recovered: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); }); }); @@ -104,13 +108,10 @@ describe("in-tab GET coalescing", () => { const p1 = api.get("/coalesce-a"); const p2 = api.get("/coalesce-a"); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(__inflightGetCount()).toBe(1); d.resolve(jsonResponse({ value: 1 })); expect(await p1).toEqual({ value: 1 }); expect(await p2).toEqual({ value: 1 }); - // Entry cleared once settled. - expect(__inflightGetCount()).toBe(0); }); it("issues a fresh fetch after the previous one settles", async () => { @@ -140,10 +141,8 @@ describe("in-tab GET coalescing", () => { const first = deferred(); fetchMock.mockReturnValueOnce(first.promise); const previousAccount = api.get("/detach-me"); - expect(__inflightGetCount()).toBe(1); detachInflightGet("/detach-me"); - expect(__inflightGetCount()).toBe(0); const second = deferred(); fetchMock.mockReturnValueOnce(second.promise); @@ -155,7 +154,6 @@ describe("in-tab GET coalescing", () => { second.resolve(jsonResponse({ companies: ["current"] })); expect(await previousAccount).toEqual({ companies: ["previous"] }); expect(await currentAccount).toEqual({ companies: ["current"] }); - expect(__inflightGetCount()).toBe(0); }); it("never coalesces mutations", async () => { @@ -188,7 +186,7 @@ describe("per-caller abort semantics", () => { expect(await p2).toEqual({ value: 2 }); }); - it("aborts the shared fetch once every caller has aborted", async () => { + it("aborts the shared fetch after all callers cancel and permits a fresh request", async () => { const d = deferred(); let sharedSignal: AbortSignal | undefined; fetchMock.mockImplementation((_url: string, init: RequestInit) => { @@ -206,7 +204,11 @@ describe("per-caller abort semantics", () => { await expect(p1).rejects.toMatchObject({ name: "AbortError" }); await expect(p2).rejects.toMatchObject({ name: "AbortError" }); expect(sharedSignal?.aborted).toBe(true); - expect(__inflightGetCount()).toBe(0); + + fetchMock.mockResolvedValueOnce(jsonResponse({ value: "fresh" })); + const fresh = api.get("/abort-b"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(await fresh).toEqual({ value: "fresh" }); }); it("rejects immediately if the caller signal is already aborted", async () => { diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index ae9d647be60..0476b2b51bc 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -165,11 +165,6 @@ export function detachInflightGet(path: string): void { inflightGets.delete(path); } -/** Test-only: number of in-flight coalesced GET keys. */ -export function __inflightGetCount(): number { - return inflightGets.size; -} - function isRequestOptions(value: unknown): value is RequestOptions { return typeof value === "object" && value !== null && "signal" in value; } diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index b4cc8fe8d82..bb2cfa485b1 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -266,6 +266,46 @@ function setInputValue(input: HTMLInputElement, value: string) { input.dispatchEvent(new Event("input", { bubbles: true })); } +function daytonaEnvironments() { + return [ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + makeEnvironment({ + id: "sandbox-1", + name: "Daytona", + driver: "sandbox", + config: { provider: "daytona" }, + }), + ]; +} + +function e2bEnvironments() { + return [ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + makeEnvironment({ + id: "sandbox-1", + name: "E2B", + driver: "sandbox", + config: { provider: "e2b" }, + }), + ]; +} + +function renderFormView(root: Root, queryClient: QueryClient, children: Parameters[0]) { + root.render( + + + {children} + + , + ); +} + +function createFormQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + async function renderForm( environments: Environment[], agentOverrides: Partial = {}, @@ -286,35 +326,24 @@ async function renderForm( document.body.appendChild(container); const root = createRoot(container); const onSave = vi.fn(); - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); @@ -332,12 +361,7 @@ async function renderCreateForm( const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); + const queryClient = createFormQueryClient(); const values = { ...defaultCreateValues, @@ -347,21 +371,15 @@ async function renderCreateForm( const onChange = vi.fn(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); @@ -466,15 +484,7 @@ function findByAriaLabel(container: HTMLElement, label: string) { async function renderCodexSandbox(agentOverrides: Partial = {}) { return renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { defaultEnvironmentId: "sandbox-1", ...agentOverrides }, { showAdapterTestEnvironmentButton: true }, ); @@ -485,15 +495,7 @@ async function renderCodexSandbox(agentOverrides: Partial = {}) { // provider advertises the login pseudo-terminal capability the login needs. async function renderVendorSandbox(agentOverrides: Partial = {}) { return renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "vendor_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides }, { showAdapterTestEnvironmentButton: true }, ); @@ -505,15 +507,7 @@ async function renderVendorSandbox(agentOverrides: Partial = {}) { // login needs. async function renderGrokSandbox(agentOverrides: Partial = {}) { return renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "grok_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides }, { showAdapterTestEnvironmentButton: true }, ); @@ -521,15 +515,7 @@ async function renderGrokSandbox(agentOverrides: Partial = {}) { async function renderClaudeSandbox(agentOverrides: Partial = {}) { return renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "claude_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides }, { showAdapterTestEnvironmentButton: true }, ); @@ -539,15 +525,7 @@ async function renderCreateClaudeSandbox( valueOverrides: Partial = {}, ) { return renderCreateForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "claude_local", defaultEnvironmentId: "sandbox-1", ...valueOverrides }, { showAdapterTestEnvironmentButton: true }, ); @@ -565,9 +543,7 @@ async function renderStatefulCreateClaudeSandbox(environments: Environment[]) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); const valuesRef: { current: typeof defaultCreateValues } = { current: { @@ -593,15 +569,7 @@ async function renderStatefulCreateClaudeSandbox(environments: Environment[]) { } await act(async () => { - root.render( - - - - - - - , - ); + renderFormView(root, queryClient, ); }); await flushReact(); @@ -1166,15 +1134,7 @@ describe("AgentConfigForm environment selector", () => { }); it("shows concise Environment copy when one runnable non-local environment exists", async () => { - const result = await renderForm([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ]); + const result = await renderForm(e2bEnvironments()); roots.push(result.root); const text = result.container.textContent ?? ""; @@ -1191,15 +1151,7 @@ describe("AgentConfigForm environment selector", () => { it("shows the environment override for Grok local agents", async () => { const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ], + e2bEnvironments(), { adapterType: "grok_local" }, ); roots.push(result.root); @@ -1213,15 +1165,7 @@ describe("AgentConfigForm environment selector", () => { it("shows the environment override for Kimi local agents", async () => { const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ], + e2bEnvironments(), { adapterType: "kimi_local" }, ); roots.push(result.root); @@ -1514,15 +1458,7 @@ describe("AgentConfigForm environment selector", () => { // no capability, so the panel stays hidden even after the auth-missing check. mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT); const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ], + e2bEnvironments(), { adapterType: "codex_local", defaultEnvironmentId: "sandbox-1" }, { showAdapterTestEnvironmentButton: true }, ); @@ -1593,15 +1529,7 @@ describe("AgentConfigForm environment selector", () => { it("hides the Login button for a Claude sandbox whose provider lacks the setup-token login capability", async () => { mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT); const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ], + e2bEnvironments(), { adapterType: "claude_local", defaultEnvironmentId: "sandbox-1" }, { showAdapterTestEnvironmentButton: true }, ); @@ -1630,15 +1558,7 @@ describe("AgentConfigForm environment selector", () => { }), ); const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "claude_local", defaultEnvironmentId: "sandbox-1" }, { showAdapterTestEnvironmentButton: true }, ); @@ -1656,15 +1576,7 @@ describe("AgentConfigForm environment selector", () => { // after the auth-missing check. mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT); const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "E2B", - driver: "sandbox", - config: { provider: "e2b" }, - }), - ], + e2bEnvironments(), { adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" }, { showAdapterTestEnvironmentButton: true }, ); @@ -1681,15 +1593,7 @@ describe("AgentConfigForm environment selector", () => { // provider capability, not the adapter name. mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT); const result = await renderForm( - [ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ], + daytonaEnvironments(), { adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" }, { showAdapterTestEnvironmentButton: true }, ); @@ -1706,23 +1610,13 @@ describe("AgentConfigForm environment selector", () => { // lifts the feedback and renders `AdapterLoginPanel` from the lifted login // descriptor. Without the descriptor the Login button never appears. mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT); - mockEnvironmentsApi.list.mockResolvedValue([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ]); + mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments()); const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); function LiftedFeedbackHarness() { const [login, setLogin] = useState(null); @@ -1753,15 +1647,7 @@ describe("AgentConfigForm environment selector", () => { } await act(async () => { - root.render( - - - - - - - , - ); + renderFormView(root, queryClient, ); }); await flushReact(); @@ -2026,12 +1912,12 @@ describe("AgentConfigForm environment selector", () => { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( + renderFormView(root, queryClient, - ); + aiConnection={{ ...intent, agentIds: [] }} chrome="onboarding" autoStart /> + ); }); await flushUntil(() => Boolean(findButton(container, "Cancel previous sign-in and retry"))); expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); @@ -2063,25 +1949,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); // Wait for the card itself, then assert it is actually there: an absence @@ -2118,25 +1996,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => onAccountBinding.mock.calls.length > 0); @@ -2149,6 +2019,7 @@ describe("AgentConfigForm environment selector", () => { // The panel narrates the bind as its own state, separate from the login's // success line — the bind is a second save that can still fail. await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false); + expect(container.textContent).toContain("Agent bound to the signed-in account"); }); it("a failed bind save renders an explicit Retry instead of silently latching the claim", async () => { @@ -2180,25 +2051,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => container.textContent?.includes("Could not bind this agent") ?? false); @@ -2256,25 +2119,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => onAccountBinding.mock.calls.length === 1); @@ -2328,25 +2183,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => container.textContent?.includes("Binding this agent") ?? false); @@ -2379,7 +2226,7 @@ describe("AgentConfigForm environment selector", () => { ; } - await act(async () => root.render()); + await act(async () => renderFormView(root, queryClient, )); await flushReact(); const nameInput = container.querySelector('input[placeholder="Agent name"]')!; setInputValue(nameInput, "Unsaved name"); @@ -2431,23 +2278,13 @@ describe("AgentConfigForm environment selector", () => { releaseSaves.push(resolve); }), ); - mockEnvironmentsApi.list.mockResolvedValue([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ]); + mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments()); const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); // The harness swaps the agent object the way the page does after a save // refresh: same mounted form, new agent identity. @@ -2467,15 +2304,7 @@ describe("AgentConfigForm environment selector", () => { ); } await act(async () => { - root.render( - - - - - - - , - ); + renderFormView(root, queryClient, ); }); await flushReact(); @@ -2982,15 +2811,7 @@ describe("AgentConfigForm environment selector", () => { .mockResolvedValueOnce(staleList) .mockResolvedValue(freshList); - const result = await renderStatefulCreateClaudeSandbox([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ]); + const result = await renderStatefulCreateClaudeSandbox(daytonaEnvironments()); roots.push(result.root); await runTest(result.container); @@ -3030,24 +2851,16 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await startLogin(container); @@ -3067,25 +2880,17 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); // Wait for the card itself, then assert it is actually there: an absence @@ -3107,24 +2912,16 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => Boolean(findButton(container, "Use saved login"))); @@ -3149,24 +2946,16 @@ describe("AgentConfigForm environment selector", () => { document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushUntil(() => Boolean(findButton(container, "Sign in"))); @@ -3554,42 +3343,26 @@ describe("AgentConfigForm environment selector", () => { await flushFake(); }; - mockEnvironmentsApi.list.mockResolvedValue([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ]); + mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments()); const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); roots.push(root); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); await flushFake(); @@ -3876,44 +3649,28 @@ describe("AgentConfigForm create-mode Claude OAuth binding", () => { // environment. The helper returns the `onSave` spy so a test can read the patch // that a stored login sends to the agent-update path. async function renderEditClaudeSandbox(agentOverrides: Partial = {}) { - mockEnvironmentsApi.list.mockResolvedValue([ - makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), - makeEnvironment({ - id: "sandbox-1", - name: "Daytona", - driver: "sandbox", - config: { provider: "daytona" }, - }), - ]); + mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments()); const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const queryClient = createFormQueryClient(); const onSave = vi.fn().mockResolvedValue(undefined); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); diff --git a/ui/src/components/AgentRunnerSelect.tsx b/ui/src/components/AgentRunnerSelect.tsx index d1cf2db832a..f2f72e2d6ff 100644 --- a/ui/src/components/AgentRunnerSelect.tsx +++ b/ui/src/components/AgentRunnerSelect.tsx @@ -3,7 +3,6 @@ import { Field } from "./agent-config-primitives"; import { SelectPopover } from "./ui/select"; import { Button } from "./ui/button"; -/** The override is request-level. Automatic selection is resolved by the server. */ export function AgentRunnerSelect({ value, onChange, diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 72d4dd8968a..5650ba0e7e8 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -9,7 +9,7 @@ import { useState, } from "react"; import { flushSync } from "react-dom"; -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -375,6 +375,22 @@ function createFileDragEvent(type: string, files: File[]) { describe("IssueChatThread", () => { let container: HTMLDivElement; + function renderIssueThread(root: ReturnType, props: Partial> = {}) { + root.render( + + {}} + enableLiveTranscriptPolling={false} + {...props} + /> + , + ); + } + beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); @@ -398,19 +414,9 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + showComposer: false, + }); }); expect(container.textContent).toContain("Jump to latest"); @@ -431,36 +437,27 @@ describe("IssueChatThread", () => { it("labels incoming iMessage bubbles without labeling board replies", () => { const root = createRoot(container); act(() => { - root.render( - - ({ - id: `comment-${source}`, - companyId: "company-1", - issueId: "issue-1", - authorAgentId: null, - authorUserId: "user-board", - authorType: "user" as const, - body: `Reply from ${source}`, - presentation: null, - metadata: source === "imessage" ? { - version: 1 as const, - sourceChannel: "imessage-photon" as const, - sections: [{ title: "iMessage Photon sender", rows: [{ type: "text" as const, text: "Linked person" }] }], - } : null, - createdAt: new Date("2026-09-12T12:00:00Z"), - updatedAt: new Date("2026-09-12T12:00:00Z"), - }))} - linkedRuns={[]} - timelineEvents={[]} - liveRuns={[]} - currentUserId="user-board" - onAdd={async () => {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: ["imessage", "board"].map((source) => ({ + id: `comment-${source}`, + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-board", + authorType: "user" as const, + body: `Reply from ${source}`, + presentation: null, + metadata: source === "imessage" ? { + version: 1 as const, + sourceChannel: "imessage-photon" as const, + sections: [{ title: "iMessage Photon sender", rows: [{ type: "text" as const, text: "Linked person" }] }], + } : null, + createdAt: new Date("2026-09-12T12:00:00Z"), + updatedAt: new Date("2026-09-12T12:00:00Z"), + })), + currentUserId: "user-board", + showComposer: false, + }); }); expect(container.querySelector("#comment-comment-imessage")?.textContent).toContain("Sent from iMessage"); expect(container.querySelector("#comment-comment-board")?.textContent).not.toContain("Sent from iMessage"); @@ -471,34 +468,25 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-current-user", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-board", + authorType: "user", + body: "1. **Readable** markdown on blue", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + ], + currentUserId: "user-board", + showComposer: false, + }); }); expect(markdownBodyRenderMock).toHaveBeenCalledWith( @@ -543,19 +531,10 @@ describe("IssueChatThread", () => { ]; act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + linkedRuns: linkedRuns, + showComposer: false, + }); }); expect(container.textContent).toContain( @@ -603,33 +582,24 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-copy", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + authorType: "user", + body: "Copy this comment", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + ], + showComposer: false, + }); }); const copyButton = container.querySelector( @@ -674,20 +644,10 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - footer={
Sibling footer
} - /> -
, - ); + renderIssueThread(root, { + showComposer: false, + footer:
Sibling footer
, + }); }); const viewport = container.querySelector('[data-testid="thread-viewport"]'); @@ -709,20 +669,10 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - onAdd={async () => {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueWorkMode: "planning", + onWorkModeChange: () => {}, + }); }); const composer = container.querySelector( @@ -749,20 +699,10 @@ describe("IssueChatThread", () => { const onWorkModeChange = vi.fn(); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueWorkMode: "standard", + onWorkModeChange: onWorkModeChange, + }); }); expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')).toBeNull(); @@ -809,20 +749,10 @@ describe("IssueChatThread", () => { const onWorkModeChange = vi.fn(); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueWorkMode: "standard", + onWorkModeChange: onWorkModeChange, + }); }); const add = container.querySelector('[data-testid="issue-chat-composer-add"]') as HTMLButtonElement; @@ -874,19 +804,9 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueWorkMode: "standard", + }); }); const composer = container.querySelector( @@ -930,27 +850,19 @@ describe("IssueChatThread", () => { ); act(() => { - root.render( - - {}} - showComposer={false} - showJumpToLatest={false} - autoScrollToHashOnInitialLoad - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + showComposer: false, + showJumpToLatest: false, + autoScrollToHashOnInitialLoad: true, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const virtualizer = container.querySelector( @@ -989,26 +901,18 @@ describe("IssueChatThread", () => { }); act(() => { - root.render( - - {}} - showComposer={false} - showJumpToLatest={false} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + showComposer: false, + showJumpToLatest: false, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const virtualRows = container.querySelectorAll( @@ -1099,24 +1003,16 @@ describe("IssueChatThread", () => { .mockImplementation(() => {}); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const jump = Array.from(container.querySelectorAll("button")).find( @@ -1163,24 +1059,16 @@ describe("IssueChatThread", () => { elementScrollToMock as unknown as typeof scrollHost.scrollTo; act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const jump = Array.from(container.querySelectorAll("button")).find( @@ -1235,24 +1123,16 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const jump = Array.from(container.querySelectorAll("button")).find( @@ -1318,24 +1198,16 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); // No jump click: initial render should preserve the page position. @@ -1376,24 +1248,16 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); act(() => { @@ -1542,22 +1406,15 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - transcriptsByRunId={transcriptsByRunId} - hasOutputForRun={(runId) => transcriptsByRunId.has(runId)} - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: linkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + transcriptsByRunId: transcriptsByRunId, + hasOutputForRun: (runId) => transcriptsByRunId.has(runId), + }); }); const virtualizerEl = container.querySelector( @@ -1610,21 +1467,12 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - onRefreshLatestComments={refreshMock} - /> - , - ); + renderIssueThread(root, { + comments: directComments, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + onRefreshLatestComments: refreshMock, + }); }); const jump = Array.from(container.querySelectorAll("button")).find( @@ -1744,22 +1592,13 @@ describe("IssueChatThread", () => { const directComments = issueChatLongThreadComments.slice(0, 12); act(() => { - root.render( - - {}} - showComposer={false} - showJumpToLatest={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: directComments, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + showComposer: false, + showJumpToLatest: false, + }); }); expect( @@ -1810,26 +1649,18 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - showJumpToLatest={false} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={(runId) => - issueChatLongThreadTranscriptsByRunId.has(runId) - } - /> - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + showComposer: false, + showJumpToLatest: false, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: (runId) => + issueChatLongThreadTranscriptsByRunId.has(runId), + }); }); const rows = container.querySelectorAll( @@ -1860,48 +1691,36 @@ describe("IssueChatThread", () => { issueChatLongThreadTranscriptsByRunId.has(runId); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + onAdd: onAdd, + showComposer: false, + showJumpToLatest: false, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: hasOutputForRun, + }); }); expect(markdownBodyRenderMock).toHaveBeenCalled(); markdownBodyRenderMock.mockClear(); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + comments: issueChatLongThreadComments, + linkedRuns: issueChatLongThreadLinkedRuns, + timelineEvents: issueChatLongThreadEvents, + agentMap: issueChatLongThreadAgentMap, + currentUserId: "user-board", + onAdd: onAdd, + showComposer: false, + showJumpToLatest: true, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: hasOutputForRun, + }); }); expect(markdownBodyRenderMock).not.toHaveBeenCalled(); @@ -1932,59 +1751,43 @@ describe("IssueChatThread", () => { ]; act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + comments: comments, + onAdd: onAdd, + onVote: onVote, + feedbackVotes: [], + showComposer: false, + }); }); expect(markdownBodyRenderMock).toHaveBeenCalled(); markdownBodyRenderMock.mockClear(); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + comments: comments, + onAdd: onAdd, + onVote: onVote, + feedbackVotes: [ + { + id: "feedback-1", + companyId: "company-1", + issueId: "issue-1", + targetType: "issue_comment", + targetId: "comment-agent-feedback", + authorUserId: "user-1", + vote: "up", + reason: null, + sharedWithLabs: false, + sharedAt: null, + consentVersion: null, + redactionSummary: null, + createdAt: new Date("2026-04-06T12:01:00.000Z"), + updatedAt: new Date("2026-04-06T12:01:00.000Z"), + }, + ], + showComposer: false, + }); }); expect(markdownBodyRenderMock).not.toHaveBeenCalled(); @@ -1997,34 +1800,25 @@ describe("IssueChatThread", () => { it("renders a genuine agent comment in the conference-room neutral bubble (PAP-97 rev 7)", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-agent-bubble", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: "agent-1", + authorUserId: null, + body: "Here is my agent reply.", + authorType: "agent" as const, + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + ], + currentUserId: "user-board", + showComposer: false, + }); }); // Conference-room canonical agent bubble: bg-card + border + tail corner. @@ -2051,48 +1845,39 @@ describe("IssueChatThread", () => { const onDeleteComment = vi.fn(async () => {}); act(() => { - root.render( - - {}} - onDeleteComment={onDeleteComment} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-owned", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-board", + body: "Delete me", + authorType: "user", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + { + id: "comment-other", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-other", + body: "Do not delete", + authorType: "user", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:01:00.000Z"), + updatedAt: new Date("2026-04-06T12:01:00.000Z"), + }, + ], + currentUserId: "user-board", + onDeleteComment: onDeleteComment, + showComposer: false, + }); }); const deleteButtons = Array.from( @@ -2127,38 +1912,29 @@ describe("IssueChatThread", () => { const root = createRoot(container); flushAct(() => { - root.render( - - {}} - onDeleteComment={async () => {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-deleted", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-board", + body: "Sensitive deleted body", + authorType: "user", + presentation: null, + metadata: null, + deletedAt: new Date("2026-04-06T12:05:00.000Z"), + deletedByType: "user", + deletedByUserId: "user-board", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:05:00.000Z"), + }, + ], + currentUserId: "user-board", + onDeleteComment: async () => {}, + showComposer: false, + }); }); expect(container.textContent).toContain("You deleted this comment"); @@ -2229,43 +2005,35 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "local-board", + body: "Please continue validation.", + authorType: "user", + presentation: null, + metadata: null, + followUpRequested: true, + createdAt: new Date("2026-03-11T10:00:00.000Z"), + updatedAt: new Date("2026-03-11T10:00:00.000Z"), + }, + ], + timelineEvents: [ + { + id: "event-1", + actorType: "agent", + actorId: "agent-1", + createdAt: new Date("2026-03-11T10:00:00.000Z"), + commentId: "comment-1", + followUpRequested: true, + }, + ], + showComposer: false, + }); }); expect(container.textContent).toContain("Follow-up"); @@ -2280,30 +2048,20 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueStatus: "todo", + blockedBy: [ + { + id: "blocker-1", + identifier: "PAP-1723", + title: "QA the install flow", + status: "blocked", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + ], + }); }); expect(container.textContent).toContain("PAP-1723"); @@ -2321,41 +2079,31 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + ], + }, + ], + }); }); expect(container.textContent).toContain("PAP-2167"); @@ -2376,59 +2124,49 @@ describe("IssueChatThread", () => { const root = createRoot(container); flushAct(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueStatus: "blocked", + liveIssueIds: new Set(["blocker-run"]), + blockerAttention: { + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 3, + coveredBlockerCount: 3, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-2002", + sampleStalledBlockerIdentifier: null, + }, + blockedBy: [ + { + id: "blocker-done", + identifier: "PAP-2001", + title: "Server work", + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + { + id: "blocker-run", + identifier: "PAP-2002", + title: "UI work", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-2", + assigneeUserId: null, + }, + { + id: "blocker-queued", + identifier: "PAP-2003", + title: "QA gate", + status: "todo", + priority: "medium", + assigneeAgentId: "agent-3", + assigneeUserId: null, + }, + ], + }); }); const notice = container.querySelector( @@ -2469,52 +2207,42 @@ describe("IssueChatThread", () => { const root = createRoot(container); flushAct(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + ], + }, + ], + }); }); const nowRunning = container.querySelector( @@ -2534,50 +2262,40 @@ describe("IssueChatThread", () => { const root = createRoot(container); flushAct(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueStatus: "blocked", + liveIssueIds: new Set(["blocker-run"]), + blockerAttention: { + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 2, + coveredBlockerCount: 2, + stalledBlockerCount: 0, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-4001", + sampleStalledBlockerIdentifier: null, + }, + blockedBy: [ + { + id: "blocker-run", + identifier: "PAP-4001", + title: "UI work", + status: "in_progress", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + { + id: "blocker-parked", + identifier: "PAP-4002", + title: "Parked backlog task", + status: "backlog", + priority: "medium", + assigneeAgentId: "agent-2", + assigneeUserId: null, + }, + ], + }); }); expect( @@ -2601,42 +2319,32 @@ describe("IssueChatThread", () => { const root = createRoot(container); flushAct(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + issueStatus: "blocked", + liveIssueIds: new Set(["blocker-1"]), + blockerAttention: { + state, + reason: state === "stalled" ? "stalled_review" : null, + unresolvedBlockerCount: 1, + coveredBlockerCount: 0, + stalledBlockerCount: state === "stalled" ? 1 : 0, + attentionBlockerCount: state === "needs_attention" ? 1 : 0, + sampleBlockerIdentifier: "PAP-5001", + sampleStalledBlockerIdentifier: + state === "stalled" ? "PAP-5001" : null, + }, + blockedBy: [ + { + id: "blocker-1", + identifier: "PAP-5001", + title: "Review task", + status: "in_review", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + }, + ], + }); }); // Blue variant never appears for non-covered states. @@ -2670,20 +2378,10 @@ describe("IssueChatThread", () => { } as Agent; act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + agentMap: new Map([["agent-1", pausedAgent]]), + currentAssigneeValue: "agent:agent-1", + }); }); expect(container.textContent).toContain("CodexCoder is paused"); @@ -2701,22 +2399,12 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - showJumpToLatest={false} - variant="embedded" - emptyMessage="No run output captured." - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + showComposer: false, + showJumpToLatest: false, + variant: "embedded", + emptyMessage: "No run output captured.", + }); }); expect(container.textContent).toContain("No run output captured."); @@ -2737,21 +2425,11 @@ describe("IssueChatThread", () => { const onAcceptInteraction = vi.fn(async () => undefined); await act(async () => { - root.render( - - {}} - onAcceptInteraction={onAcceptInteraction} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [createSuggestedTasksInteraction()], + onAcceptInteraction: onAcceptInteraction, + showComposer: false, + }); }); const acceptButton = Array.from(container.querySelectorAll("button")).find( @@ -2781,38 +2459,28 @@ describe("IssueChatThread", () => { const onAcceptInteraction = vi.fn(async () => undefined); await act(async () => { - root.render( - - {}} - onAcceptInteraction={onAcceptInteraction} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + { + clientKey: "child", + parentClientKey: "root", + title: "Child task", + }, + ], + }, + }), + ], + onAcceptInteraction: onAcceptInteraction, + showComposer: false, + }); }); const childCheckbox = container.querySelector( @@ -2849,21 +2517,11 @@ describe("IssueChatThread", () => { const onSubmitInteractionAnswers = vi.fn(async () => undefined); await act(async () => { - root.render( - - {}} - onSubmitInteractionAnswers={onSubmitInteractionAnswers} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [createQuestionInteraction()], + onSubmitInteractionAnswers: onSubmitInteractionAnswers, + showComposer: false, + }); }); const optionButton = Array.from(container.querySelectorAll("button")).find( @@ -2900,21 +2558,11 @@ describe("IssueChatThread", () => { const onSubmitInteractionAnswers = vi.fn(async () => undefined); await act(async () => { - root.render( - - {}} - onSubmitInteractionAnswers={onSubmitInteractionAnswers} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [createQuestionInteraction()], + onSubmitInteractionAnswers: onSubmitInteractionAnswers, + showComposer: false, + }); }); const otherButton = Array.from(container.querySelectorAll("button")).find( @@ -2966,21 +2614,11 @@ describe("IssueChatThread", () => { const onCancelInteraction = vi.fn(async () => undefined); await act(async () => { - root.render( - - {}} - onCancelInteraction={onCancelInteraction} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [createQuestionInteraction()], + onCancelInteraction: onCancelInteraction, + showComposer: false, + }); }); const cancelButton = Array.from(container.querySelectorAll("button")).find( @@ -3008,22 +2646,12 @@ describe("IssueChatThread", () => { const root = createRoot(container); await act(async () => { - root.render( - - {}} - currentUserId="user-1" - userLabelMap={new Map([["user-1", "Dotta"]])} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [createExpiredRequestConfirmationInteraction()], + currentUserId: "user-1", + userLabelMap: new Map([["user-1", "Dotta"]]), + showComposer: false, + }); }); expect(container.textContent).toContain("Dotta"); @@ -3054,20 +2682,10 @@ describe("IssueChatThread", () => { const root = createRoot(container); await act(async () => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + interactions: [expiredSecretProposalInteraction], + showComposer: false, + }); }); expect(container.textContent).toContain("Secret binding requested"); @@ -3086,33 +2704,24 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: "agent-1", + authorUserId: null, + body: "Agent summary", + authorType: "agent", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + ], + showComposer: false, + }); }); expect(container.textContent).toContain("Agent summary"); @@ -3171,69 +2780,51 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-hold", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + body: "Need a quick update", + authorType: "user", + presentation: null, + metadata: null, + queueState: "queued", + queueReason: "hold", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + }, + ], + showComposer: false, + }); }); expect(container.textContent).toContain("Deferred wake"); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-active-run", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + body: "Queue behind active run", + authorType: "user", + presentation: null, + metadata: null, + queueState: "queued", + queueReason: "active_run", + createdAt: new Date("2026-04-06T12:01:00.000Z"), + updatedAt: new Date("2026-04-06T12:01:00.000Z"), + }, + ], + showComposer: false, + }); }); expect(container.textContent).toContain("Queued"); @@ -3508,19 +3099,9 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - draftKey="issue-chat-draft:test-1" - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + draftKey: "issue-chat-draft:test-1", + }); }); const editor = container.querySelector( @@ -3581,18 +3162,7 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root); }); const dock = container.querySelector( @@ -3628,20 +3198,10 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - imageUploadHandler={async () => "/api/attachments/image/content"} - onAttachImage={async () => undefined} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + imageUploadHandler: async () => "/api/attachments/image/content", + onAttachImage: async () => undefined, + }); }); const composer = container.querySelector( @@ -3707,21 +3267,11 @@ describe("IssueChatThread", () => { }); await act(async () => { - root.render( - - {}} - onAttachImage={onAttachImage} - issueWorkMode="standard" - onWorkModeChange={() => {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + onAttachImage: onAttachImage, + issueWorkMode: "standard", + onWorkModeChange: () => {}, + }); }); const composer = container.querySelector( @@ -3858,19 +3408,10 @@ describe("IssueChatThread", () => { ); const root = createRoot(container); await act(async () => - root.render( - - - , - ), + renderIssueThread(root, { + onAdd: onAdd, + draftKey: key, + }), ); const editor = container.querySelector( 'textarea[aria-label="Issue chat editor"]', @@ -3976,26 +3517,17 @@ describe("IssueChatThread", () => { ); const root = createRoot(container); await act(async () => - root.render( - - - , - ), + renderIssueThread(root, { + onAdd: onAdd, + draftKey: "legacy-awaited-reassignment", + enableReassign: true, + reassignOptions: [ + { id: "user:board", label: "Board" }, + { id: "user:reviewer", label: "Reviewer" }, + ], + currentAssigneeValue: "user:board", + suggestedAssigneeValue: "user:reviewer", + }), ); const editor = container.querySelector( 'textarea[aria-label="Issue chat editor"]', @@ -4035,20 +3567,10 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - imageUploadHandler={async () => "/api/attachments/image/content"} - onAttachImage={async () => undefined} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + imageUploadHandler: async () => "/api/attachments/image/content", + onAttachImage: async () => undefined, + }); }); const composer = container.querySelector( @@ -4109,19 +3631,9 @@ describe("IssueChatThread", () => { })); await act(async () => { - root.render( - - {}} - onAttachImage={onAttachImage} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + onAttachImage: onAttachImage, + }); }); const editor = container.querySelector( @@ -4153,32 +3665,23 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + comments: [ + { + id: "comment-spacer-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "user-1", + body: "hello", + authorType: "user", + presentation: null, + metadata: null, + createdAt: new Date("2026-04-22T12:00:00.000Z"), + updatedAt: new Date("2026-04-22T12:00:00.000Z"), + }, + ], + }); }); const spacer = container.querySelector( @@ -4196,19 +3699,9 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + showComposer: false, + }); }); const spacer = container.querySelector( @@ -4226,20 +3719,11 @@ describe("IssueChatThread", () => { const onAdd = vi.fn().mockResolvedValue(undefined); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + issueStatus: "done", + currentAssigneeValue: "agent:agent-1", + onAdd: onAdd, + }); }); expect(container.textContent).not.toContain("Re-open"); @@ -4278,25 +3762,16 @@ describe("IssueChatThread", () => { const onAdd = vi.fn().mockResolvedValue(undefined); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + onAdd: onAdd, + enableReassign: true, + reassignOptions: [ + { id: "", label: "No responsible" }, + { id: "agent:agent-1", label: "Agent 1" }, + ], + currentAssigneeValue: "", + suggestedAssigneeValue: "", + }); }); const editor = container.querySelector( @@ -4354,25 +3829,16 @@ describe("IssueChatThread", () => { const onAdd = vi.fn().mockResolvedValue(undefined); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + onAdd: onAdd, + enableReassign: true, + reassignOptions: [ + { id: "", label: "No responsible" }, + { id: "agent:agent-1", label: "Agent 1" }, + ], + currentAssigneeValue: "", + suggestedAssigneeValue: "", + }); }); const editor = container.querySelector( @@ -4428,25 +3894,16 @@ describe("IssueChatThread", () => { const onAdd = vi.fn().mockResolvedValue(undefined); act(() => { - root.render( - - - , - ); + renderIssueThread(root, { + onAdd: onAdd, + enableReassign: true, + reassignOptions: [ + { id: "", label: "No responsible" }, + { id: "agent:agent-1", label: "Agent 1" }, + ], + currentAssigneeValue: "agent:agent-1", + suggestedAssigneeValue: "agent:agent-1", + }); }); const editor = container.querySelector( @@ -4494,19 +3951,9 @@ describe("IssueChatThread", () => { }); act(() => { - root.render( - - {}} - composerRef={composerRef} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + composerRef: composerRef, + }); }); const composer = container.querySelector( @@ -4553,19 +4000,9 @@ describe("IssueChatThread", () => { }); act(() => { - root.render( - - {}} - composerRef={composerRef} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + composerRef: composerRef, + }); }); const editor = container.querySelector( @@ -4592,47 +4029,27 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root); }); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + liveRuns: [ + { + id: "run-1", + issueId: "issue-1", + status: "running", + invocationSource: "comment", + triggerDetail: null, + startedAt: "2026-04-06T12:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-06T12:00:00.000Z", + agentId: "agent-1", + agentName: "Agent 1", + adapterType: "codex_local", + }, + ], + }); }); expect(restoreComposerViewportSnapshotMock).not.toHaveBeenCalled(); @@ -4653,47 +4070,27 @@ describe("IssueChatThread", () => { }); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root); }); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + liveRuns: [ + { + id: "run-1", + issueId: "issue-1", + status: "running", + invocationSource: "comment", + triggerDetail: null, + startedAt: "2026-04-06T12:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-06T12:00:00.000Z", + agentId: "agent-1", + agentName: "Agent 1", + adapterType: "codex_local", + }, + ], + }); }); expect(restoreComposerViewportSnapshotMock).toHaveBeenCalled(); @@ -4708,56 +4105,45 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + liveRuns: [ + { + id: "run-1", + issueId: "issue-1", + status: "running", + invocationSource: "comment", + triggerDetail: null, + startedAt: "2026-04-06T12:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-06T12:00:00.000Z", + agentId: "agent-1", + agentName: "Agent 1", + adapterType: "codex_local", + }, + ], + transcriptsByRunId: new Map([ + [ + "run-1", + [ + { + kind: "tool_call", + ts: "2026-04-06T12:00:10.000Z", + name: "command_execution", + toolUseId: "tool-1", + input: { command: "pnpm test" }, + }, + { + kind: "tool_result", + ts: "2026-04-06T12:00:20.000Z", + toolUseId: "tool-1", + toolName: "command_execution", + content: "Tests passed", + isError: false, + }, + ], + ], + ]), + }); }); expect(container.textContent).toContain("Working"); @@ -4772,35 +4158,25 @@ describe("IssueChatThread", () => { const root = createRoot(container); act(() => { - root.render( - - {}} - enableLiveTranscriptPolling={false} - /> - , - ); + renderIssueThread(root, { + activeRun: { + id: "run-1", + issueId: "issue-1", + status: "running", + invocationSource: "comment", + triggerDetail: null, + startedAt: "2026-04-06T12:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-06T12:00:00.000Z", + agentId: "agent-1", + agentName: "Agent 1", + adapterType: "codex_local", + currentStatusMessage: "Syncing git worktree to environment", + currentStatusUpdatedAt: "2026-04-06T12:00:05.000Z", + currentToolName: "bash", + lastEventAt: new Date(Date.now() - 2000).toISOString(), + }, + }); }); expect(container.textContent).toContain("Working..."); @@ -4818,21 +4194,12 @@ describe("IssueChatThread", () => { const run = issueChatLongThreadLinkedRuns[2]; act(() => { - root.render( - - {}} - showComposer={false} - enableLiveTranscriptPolling={false} - transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} - hasOutputForRun={() => true} - /> - , - ); + renderIssueThread(root, { + linkedRuns: [run], + showComposer: false, + transcriptsByRunId: issueChatLongThreadTranscriptsByRunId, + hasOutputForRun: () => true, + }); }); const header = Array.from(container.querySelectorAll("button")).find((button) => diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 6fd44f52b1e..bd02c27385b 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -439,6 +439,18 @@ function createExecutionState(overrides: Partial = {}): Iss }; } +function createRelation(id: string, identifier: string, title: string, status: Issue["status"] = "todo") { + return { + id, + identifier, + title, + status, + priority: "medium" as const, + assigneeAgentId: null, + assigneeUserId: null, + }; +} + function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps, hiddenSettings: string[] = []) { const queryClient = new QueryClient({ defaultOptions: { @@ -663,24 +675,8 @@ describe("IssueProperties", () => { }); it("stacks property chips vertically and aligns each label with the first chip", async () => { - const blockedBy = Array.from({ length: 3 }, (_, index) => ({ - id: `blocker-${index + 1}`, - identifier: `BLOCK-${index + 1}`, - title: `Blocker ${index + 1}`, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - })) as NonNullable; - const blocks = Array.from({ length: 3 }, (_, index) => ({ - id: `blocked-${index + 1}`, - identifier: `BLOCKED-${index + 1}`, - title: `Blocked task ${index + 1}`, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - })) as NonNullable; + const blockedBy = Array.from({ length: 3 }, (_, index) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) as NonNullable; + const blocks = Array.from({ length: 3 }, (_, index) => (createRelation(`blocked-${index + 1}`, `BLOCKED-${index + 1}`, `Blocked task ${index + 1}`))) as NonNullable; const labels = Array.from({ length: 3 }, (_, index) => createLabel({ id: `label-${index + 1}`, name: `Label ${index + 1}`, @@ -1416,15 +1412,7 @@ describe("IssueProperties", () => { const root = renderProperties(container, { issue: createIssue({ blockedBy: [ - { - id: "issue-2", - identifier: "PAP-2", - title: "Existing blocker", - status: "in_progress", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"), ], }), childIssues: [], @@ -1516,24 +1504,8 @@ describe("IssueProperties", () => { const root = renderProperties(container, { issue: createIssue({ blockedBy: [ - { - id: "issue-2", - identifier: "PAP-2", - title: "Existing blocker", - status: "in_progress", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, - { - id: "issue-4", - identifier: "PAP-4", - title: "Keep blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"), + createRelation("issue-4", "PAP-4", "Keep blocker"), ], }), childIssues: [], @@ -1569,24 +1541,8 @@ describe("IssueProperties", () => { const root = renderProperties(container, { issue: createIssue({ blockedBy: [ - { - id: "issue-2", - identifier: "PAP-2", - title: "Existing blocker", - status: "in_progress", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, - { - id: "issue-4", - identifier: "PAP-4", - title: "Keep blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"), + createRelation("issue-4", "PAP-4", "Keep blocker"), ], }), childIssues: [], @@ -1617,15 +1573,7 @@ describe("IssueProperties", () => { }); it("summarizes long blocked-by and subtask relationships in their picker triggers", async () => { - const blockedBy = Array.from({ length: 7 }, (_, index) => ({ - id: `blocker-${index + 1}`, - identifier: `BLOCK-${index + 1}`, - title: `Blocker ${index + 1}`, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - })) as NonNullable; + const blockedBy = Array.from({ length: 7 }, (_, index) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) as NonNullable; const childIssues = Array.from({ length: 7 }, (_, index) => createIssue({ id: `child-${index + 1}`, identifier: `SUB-${index + 1}`, @@ -1677,15 +1625,7 @@ describe("IssueProperties", () => { enableTaskWatchdogs: false, enableClassicTaskInterface: true, }); - const blocking = Array.from({ length: 7 }, (_, index) => ({ - id: `blocking-${index + 1}`, - identifier: `BLOCKING-${index + 1}`, - title: `Blocking issue ${index + 1}`, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - })) as NonNullable; + const blocking = Array.from({ length: 7 }, (_, index) => (createRelation(`blocking-${index + 1}`, `BLOCKING-${index + 1}`, `Blocking issue ${index + 1}`))) as NonNullable; const relatedIssues = Array.from({ length: 7 }, (_, index) => ({ id: `related-${index + 1}`, identifier: `RELATED-${index + 1}`, @@ -1803,15 +1743,7 @@ describe("IssueProperties", () => { }); it("updates the blocked-by relationship summary when the issue changes", async () => { - const blockedBy = Array.from({ length: 7 }, (_, index) => ({ - id: `blocker-${index + 1}`, - identifier: `BLOCK-${index + 1}`, - title: `Blocker ${index + 1}`, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - })) as NonNullable; + const blockedBy = Array.from({ length: 7 }, (_, index) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) as NonNullable; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -2134,30 +2066,14 @@ describe("IssueProperties", () => { relatedWork: { outbound: [ { - issue: { - id: "issue-22", - identifier: "PAP-22", - title: "Related task", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + issue: createRelation("issue-22", "PAP-22", "Related task"), mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }], }, ], inbound: [ { - issue: { - id: "issue-23", - identifier: "PAP-23", - title: "Mentions this task", - status: "in_progress", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + issue: createRelation("issue-23", "PAP-23", "Mentions this task", "in_progress"), mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-1" }], }, @@ -2193,65 +2109,25 @@ describe("IssueProperties", () => { const root = renderProperties(container, { issue: createIssue({ blockedBy: [ - { - id: "issue-22", - identifier: "PAP-22", - title: "Blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + createRelation("issue-22", "PAP-22", "Blocker"), ], blocks: [ - { - id: "issue-33", - identifier: "PAP-33", - title: "Blocked issue", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + createRelation("issue-33", "PAP-33", "Blocked issue"), ], relatedWork: { outbound: [ { - issue: { - id: "issue-22", - identifier: "PAP-22", - title: "Blocker", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + issue: createRelation("issue-22", "PAP-22", "Blocker"), mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }], }, { - issue: { - id: "issue-33", - identifier: "PAP-33", - title: "Blocked issue", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + issue: createRelation("issue-33", "PAP-33", "Blocked issue"), mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-33" }], }, { - issue: { - id: "child-44", - identifier: "PAP-44", - title: "Child issue", - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - }, + issue: createRelation("child-44", "PAP-44", "Child issue"), mentionCount: 1, sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-44" }], }, diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index 3921e020748..48086260041 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -214,6 +214,10 @@ describe("IssueThreadInteractionCard", () => { expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeTruthy(); expect(host.textContent).toContain("Connect"); expect(host.textContent).toContain("Not now"); + const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim()); + expect(labels).toContain("Connect"); + expect(labels).toContain("Not now"); + expect(host.textContent).toContain("Access is added only for this agent"); const loadButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Connect", ); @@ -241,16 +245,7 @@ describe("IssueThreadInteractionCard", () => { expect(dialog?.textContent).toContain("Complete connection setup without leaving this task."); }); - it("keeps connection resolution controls exclusive to the addressed user", () => { - const host = renderCard({ - interaction: pendingConnectionIntentInteraction, - currentUserId: "another-user", - }); - - expect(host.querySelector('[data-testid="connection-intent-waiting"]')).toBeTruthy(); - expect(Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim())).not.toContain("Connect"); - expect(host.textContent).not.toContain("Not now"); - }); + ; it("exposes pending question options as selectable radio and checkbox controls", () => { const host = renderCard({ @@ -258,6 +253,8 @@ describe("IssueThreadInteractionCard", () => { onSubmitInteractionAnswers: vi.fn(), }); + expect(host.childElementCount).toBeGreaterThan(0); + const singleGroup = host.querySelector('[role="radiogroup"]'); expect(singleGroup?.getAttribute("aria-labelledby")).toBe( "interaction-questions-default-collapse-depth-prompt", @@ -433,15 +430,7 @@ describe("IssueThreadInteractionCard", () => { expect(host.textContent).toBe(""); }); - it("still renders a legitimate ask_user_questions card", () => { - const host = renderCard({ - interaction: pendingAskUserQuestionsInteraction, - onSubmitInteractionAnswers: vi.fn(), - }); - - expect(host.childElementCount).toBeGreaterThan(0); - expect(host.querySelectorAll('[role="radio"]').length).toBeGreaterThan(0); - }); + ; it("keeps a closed native select set closed while preserving direct-question defaults", () => { const closed = { @@ -1589,17 +1578,7 @@ describe("IssueThreadInteractionCard connection-authorization card", () => { describe("IssueThreadInteractionCard connection-intent card", () => { const USER_LABELS = new Map([[issueThreadInteractionFixtureMeta.currentUserId, "Carol"]]); - it("offers the addressed user the shared setup entry point and Not now", () => { - const host = renderCard({ - interaction: pendingConnectionIntentInteraction, - currentUserId: issueThreadInteractionFixtureMeta.currentUserId, - }); - expect(host.querySelector('[data-testid="connection-intent-actions"]')).not.toBeNull(); - const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim()); - expect(labels).toContain("Connect"); - expect(labels).toContain("Not now"); - expect(host.textContent).toContain("Access is added only for this agent"); - }); + ; it("shows another viewer only who it is waiting for", () => { const host = renderCard({ @@ -1612,6 +1591,7 @@ describe("IssueThreadInteractionCard connection-intent card", () => { expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeNull(); expect(host.querySelectorAll("button")).toHaveLength(0); expect(host.innerHTML).not.toContain("authorizationUrl"); + expect(host.textContent).not.toContain("Not now"); }); it("renders retry and connected terminal states without generic confirmation actions", () => { diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 4c07fdef31a..827943ecac0 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -584,9 +584,12 @@ describe("MarkdownBody", () => { it("opens external links in a new tab with safe rel attributes", () => { const html = renderMarkdown("[docs](https://example.com/docs)"); - expect(html).toContain('href="https://example.com/docs"'); + expect(html).toContain("s]*lucide-external-link/); }); it("opens GitHub links in a new tab", () => { @@ -623,22 +626,9 @@ describe("MarkdownBody", () => { expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-(--va-0_125em)"'); }); - it("does not prefix non-GitHub markdown links with the GitHub icon", () => { - const html = renderMarkdown("[docs](https://example.com/docs)"); + ; - expect(html).toContain(' { - const html = renderMarkdown("[docs](https://example.com/docs)"); - - expect(html).toContain('target="_blank"'); - expect(html).toContain("lucide-external-link"); - // Last character "s" must sit in a no-wrap span with the icon so the - // indicator never wraps away from the link text. - expect(html).toMatch(/s]*lucide-external-link/); - }); + ; it("does not render the new-tab icon on internal links", () => { const html = renderMarkdown("[settings](/company/settings)"); diff --git a/ui/src/components/OnboardingWizard.step.test.tsx b/ui/src/components/OnboardingWizard.step.test.tsx index 54b53bda774..a759b55f961 100644 --- a/ui/src/components/OnboardingWizard.step.test.tsx +++ b/ui/src/components/OnboardingWizard.step.test.tsx @@ -178,7 +178,6 @@ describe("OnboardingWizard — which step it lands on", () => { dialogState.onboardingOpen = false; dialogState.onboardingOptions = {}; dialogState.onboardingRouteDismissed = false; - // The scoped discovery contract must resolve before the hire seam runs. mockAdaptersApi.list.mockResolvedValue(["claude_local", "codex_local"].map(type => ({ type, loaded: true, disabled: false, defaultRunner: "legacy", supportedRunners: ["legacy"], }))); diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index b6112c59617..d7c39aafa2e 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -277,6 +277,16 @@ async function flushReact() { }); } +function renderWizardView(root: ReturnType, queryClient: QueryClient) { + return act(async () => { + root.render( + + + , + ); + }); +} + const SESSION_USER_ID = "user-b"; function render() { @@ -427,13 +437,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; const { root, queryClient } = render(); const renderTree = () => - act(async () => { - root.render( - - - , - ); - }); + renderWizardView(root, queryClient); await renderTree(); await flushReact(); return { root, renderTree }; @@ -521,13 +525,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ]); mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); const clickText = async (match: (t: string) => boolean) => { @@ -597,13 +595,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; const { root, queryClient } = render(); const renderTree = () => - act(async () => { - root.render( - - - , - ); - }); + renderWizardView(root, queryClient); await renderTree(); await flushReact(); @@ -777,13 +769,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockResolvedValue([]); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); const clickByText = async (match: (text: string) => boolean) => { @@ -847,6 +833,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( "No stored Claude login was found for this agent.", ); + expect(document.body.textContent).toContain("No working authentication was found"); + await act(async () => root.unmount()); }); @@ -1245,31 +1233,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await act(async () => root.unmount()); }); - it("blocks the hire when the probe reports warn with adapter_auth_missing and no stored login exists", async () => { - // The default `beforeEach` mock already rejects with a 404 `ApiError`. - mockAgentsApi.testEnvironment.mockResolvedValue({ - adapterType: "claude_local", - status: "warn" as const, - checks: [ - { - code: ADAPTER_AUTH_MISSING_CHECK_CODE, - level: "warn" as const, - message: "No stored Claude login was found for this agent.", - }, - ], - testedAt: new Date().toISOString(), - }); - const { root, clickByText } = await openConnectStep(); - - await clickByText((t) => isArcPrimary(t)); - - expect(mockAgentsApi.hire).not.toHaveBeenCalled(); - expect(document.body.textContent).toContain( - "No working authentication was found", - ); - - await act(async () => root.unmount()); - }); + ; it("reads the stored-login status once for each create attempt", async () => { mockAgentsApi.getClaudeOAuthTokenStatus.mockReset(); @@ -1326,13 +1290,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( const { container, root, queryClient } = render(); const renderTree = () => - act(async () => { - root.render( - - - , - ); - }); + renderWizardView(root, queryClient); await renderTree(); await flushReact(); @@ -1399,13 +1357,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); const companyNameInput = document.querySelector("input") as HTMLInputElement; @@ -1456,13 +1408,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); expect(document.querySelector("#onboarding-agent-name")).toBeNull(); @@ -1518,13 +1464,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ]); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Falls back to the wizard's default first step, not the stale step 4 @@ -1558,13 +1498,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockRejectedValue(new Error("company list unavailable")); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // It mounts: the companies query sets `retry: false`, and with no @@ -1603,13 +1537,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompany.loading = false; const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); expect(localStorage).toHaveBeenCalled(); @@ -1646,13 +1574,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed")); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Mounted rather than blank... @@ -1694,13 +1616,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockRejectedValue(new Error("company list unavailable")); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBe(draft); @@ -1738,13 +1654,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed for the new account")); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Account A's agent name must not appear in account B's wizard. @@ -1792,13 +1702,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ]); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Mounted — so this is a real observation, not an unmounted false pass. @@ -1828,13 +1732,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ); const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).not.toBeNull(); @@ -1866,13 +1764,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( // B's session: the refetch fails, so A's data is retained. mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed")); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Mounted, so this observes the real thing rather than an empty document. @@ -1908,13 +1800,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }); mockCompaniesApi.list.mockReturnValue(new Promise(() => {})); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); // Nothing mounted, so nothing could have written over the draft. @@ -1932,13 +1818,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( const { root, queryClient } = render(); mockCompaniesApi.list.mockReturnValue(new Promise(() => {})); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); await flushReact(); expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBeNull(); @@ -2007,13 +1887,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockDialog.onboardingOptions = {}; const { root, queryClient } = render(); - await act(async () => { - root.render( - - - , - ); - }); + await renderWizardView(root, queryClient); // The login-panel gate chains several dependent queries (environments, // instance settings, environment capabilities, then the auth signal // itself), each settling on its own render. One flush is not always @@ -3057,6 +2931,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( it("renders no 'Use saved login' control", async () => { mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); const { root } = await openStep4({ adapterType: "claude_local" }); + await pickSource(/Claude/); + expect(document.body.textContent).toContain("Sign in to Claude then come back and enter authorization code"); expect(document.body.textContent).not.toContain("Use saved login"); await act(async () => root.unmount()); }); @@ -3180,7 +3056,6 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); try { await act(async () => { - // A warm value remains successful while its current fetch is pending. if (pending === "environments") queryClient.setQueryData(queryKeys.environments.list("company-new"), [LOCAL_ENVIRONMENT]); else if (pending === "settings") queryClient.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: null }); else queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false }); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 10d9b7bcbf4..931787e0423 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1060,9 +1060,6 @@ function OnboardingWizardInner({ }, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" && Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep(), ); - // A result from a previous selection must not hire or advance this wizard. - // Discovery starts after environment settings resolve. A later target or - // runner change invalidates the configuration an in-flight test was built from. useEffect(() => { autoConnectStartedRef.current = false; hiringAgentRef.current = null; diff --git a/ui/src/components/decision-models/DecisionModelSettings.test.tsx b/ui/src/components/decision-models/DecisionModelSettings.test.tsx index 1804ce583aa..88039176b3f 100644 --- a/ui/src/components/decision-models/DecisionModelSettings.test.tsx +++ b/ui/src/components/decision-models/DecisionModelSettings.test.tsx @@ -6,7 +6,7 @@ import { expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { decisionModelsApi } from "@/api/decision-models"; import { DecisionModelSettingsSection, DecisionModelSettingsView } from "./DecisionModelSettings"; -import { choices, configured, result } from "../../../storybook/stories/decision-models/fixtures"; +import { choices, configured, result } from "../../../storybook/support/decision-models/fixtures"; vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-storybook" }) })); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/ui/src/components/fast-responses/FastResponseSettings.test.tsx b/ui/src/components/fast-responses/FastResponseSettings.test.tsx index fb6f8502a52..6910968c245 100644 --- a/ui/src/components/fast-responses/FastResponseSettings.test.tsx +++ b/ui/src/components/fast-responses/FastResponseSettings.test.tsx @@ -5,7 +5,7 @@ import { MemoryRouter } from "react-router-dom"; import { expect, it, vi } from "vitest"; import { updateFastResponseSchema } from "@paperclipai/shared"; import { FastResponseSettingsView } from "./FastResponseSettings"; -import { choices } from "../../../storybook/stories/decision-models/fixtures"; +import { choices } from "../../../storybook/support/decision-models/fixtures"; vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-storybook" }), })); diff --git a/ui/src/hooks/useSecondTick.test.tsx b/ui/src/hooks/useSecondTick.test.tsx index e9daa23eadd..0eb979b57dc 100644 --- a/ui/src/hooks/useSecondTick.test.tsx +++ b/ui/src/hooks/useSecondTick.test.tsx @@ -3,17 +3,16 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { __secondTickInternals, useSecondTick } from "./useSecondTick"; +import { useSecondTick } from "./useSecondTick"; function mount(active: boolean) { const container = document.createElement("div"); const root = createRoot(container); function Probe() { - useSecondTick(active); - return null; + return {useSecondTick(active)}; } flushSync(() => root.render()); - return { unmount: () => flushSync(() => root.unmount()) }; + return { container, unmount: () => flushSync(() => root.unmount()) }; } describe("useSecondTick", () => { @@ -24,22 +23,31 @@ describe("useSecondTick", () => { const a = mount(true); const b = mount(true); - expect(__secondTickInternals.subscriberCount()).toBe(2); - expect(__secondTickInternals.isRunning()).toBe(true); + expect(vi.getTimerCount()).toBe(1); + flushSync(() => vi.advanceTimersByTime(1000)); + expect(a.container.textContent).toBe("1"); + expect(b.container.textContent).toBe("1"); a.unmount(); - expect(__secondTickInternals.subscriberCount()).toBe(1); - expect(__secondTickInternals.isRunning()).toBe(true); // still one subscriber + expect(vi.getTimerCount()).toBe(1); + flushSync(() => vi.advanceTimersByTime(1000)); + expect(b.container.textContent).toBe("2"); b.unmount(); - expect(__secondTickInternals.subscriberCount()).toBe(0); - expect(__secondTickInternals.isRunning()).toBe(false); // timer stopped when idle + expect(vi.getTimerCount()).toBe(0); }); it("does not subscribe or start a timer when inactive", () => { const c = mount(false); - expect(__secondTickInternals.subscriberCount()).toBe(0); - expect(__secondTickInternals.isRunning()).toBe(false); + expect(vi.getTimerCount()).toBe(0); + flushSync(() => vi.advanceTimersByTime(3000)); + expect(c.container.textContent).toBe("0"); + const active = mount(true); + flushSync(() => vi.advanceTimersByTime(1000)); + expect(active.container.textContent).toBe("1"); + expect(c.container.textContent).toBe("0"); + active.unmount(); + expect(vi.getTimerCount()).toBe(0); c.unmount(); }); }); diff --git a/ui/src/hooks/useSecondTick.ts b/ui/src/hooks/useSecondTick.ts index 8065b6bf9ad..962fe84a935 100644 --- a/ui/src/hooks/useSecondTick.ts +++ b/ui/src/hooks/useSecondTick.ts @@ -46,9 +46,3 @@ export function useSecondTick(active: boolean): number { }, [active]); return tick; } - -/** Test-only hook to observe the shared ticker's internal state. */ -export const __secondTickInternals = { - subscriberCount: () => subscribers.size, - isRunning: () => intervalId !== null, -}; diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 57d1004bd55..e539e9b59af 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -1775,8 +1775,6 @@ export function CompanyImport() { applyChanges(original.adapterSchemaValues ?? {}, configVals.adapterSchemaValues ?? {}); applyChanges(baseline, edited); if (selectedType === "claude_local") { - // The native builder preserves source schema fields. Project common - // form edits separately so a removed binding cannot be restored there. const commonBuild = getUIAdapter("claude_local").buildAdapterConfig; const commonBefore = commonBuild(original); const commonAfter = commonBuild(configVals); diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 7249989b805..aa1e57af873 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1306,6 +1306,12 @@ describe("IssueDetail", () => { let queryClient: QueryClient; let consoleErrorSpy: ReturnType; + function renderIssueView(children: ReactNode = ) { + root.render( + {children}, + ); + } + beforeEach(() => { localStorage.clear(); sessionStorage.clear(); @@ -1434,7 +1440,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(cleared); return cleared; }); - await act(async () => root.render()); + await act(async () => renderIssueView()); await waitForAssertion(() => expect(container.querySelector('[aria-label="Cancel monitor"]')).not.toBeNull()); await act(async () => (container.querySelector('[aria-label="Cancel monitor"]') as HTMLButtonElement).click()); expect(mockIssuesApi.update).not.toHaveBeenCalled(); @@ -1448,7 +1454,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", executionState: monitor })); const check = createDeferred(); mockIssuesApi.checkMonitorNow.mockReturnValue(check.promise); - await act(async () => root.render()); + await act(async () => renderIssueView()); let button: HTMLButtonElement | undefined; await waitForAssertion(() => { button = Array.from(container.querySelectorAll("button")).find((entry) => entry.textContent === "Check now"); @@ -1464,7 +1470,7 @@ describe("IssueDetail", () => { queryClient.setQueryData(queryKeys.issues.detail("PAP-2"), second); mockRouteParams.issueId = "PAP-2"; mockLocation.pathname = "/issues/PAP-2"; - await act(async () => root.render()); + await act(async () => renderIssueView()); if (lateResponse) check.reject(new Error("First task monitor failed")); await flushReact(); await waitForAssertion(() => { @@ -1494,7 +1500,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(issue); mockIssuesApi.update.mockClear(); mockIssuesApi.update.mockReturnValueOnce(failedUpdate.promise).mockReturnValueOnce(successfulUpdate.promise); - await act(async () => root.render()); + await act(async () => renderIssueView()); let properties!: { onUpdate: (data: Record) => void }; await waitForAssertion(() => { properties = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props; @@ -1519,7 +1525,7 @@ describe("IssueDetail", () => { const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review" }); mockIssuesApi.get.mockResolvedValue(canonical); await act(async () => { - root.render( canonical }} />); + renderIssueView( canonical }} />); }); await flushReact(); expect(mockNavigate).not.toHaveBeenCalled(); @@ -1530,7 +1536,7 @@ describe("IssueDetail", () => { expect(breadcrumbs[0].leadingKey).toBe(`agent:${agent.id}:${JSON.stringify(agent.appearance)}`); const updatedAgent = { ...agent, appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "deep-tide" } } as Agent; await act(async () => { - root.render( canonical }} />); + renderIssueView( canonical }} />); }); await flushReact(); const [updatedBreadcrumbs] = mockSetBreadcrumbs.mock.calls.at(-1)!; @@ -1548,7 +1554,7 @@ describe("IssueDetail", () => { mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Clarify this goal" })); mockIssuesApi.uploadAttachment.mockResolvedValue(createAttachment({ id: "first-upload" })); await act(async () => { - root.render(); + renderIssueView(); }); await flushReact(); expect(ensureIssue).not.toHaveBeenCalled(); @@ -1580,7 +1586,7 @@ describe("IssueDetail", () => { mockIssuesApi.update.mockRejectedValueOnce(new Error("Mode save failed")).mockResolvedValue({ ...canonical, workMode: "ask" }); mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Research only" })); const renderChat = async (issue: Issue | null) => { - await act(async () => root.render()); + await act(async () => renderIssueView()); await flushReact(); return mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { onWorkModeChange: (mode: string) => Promise; onAdd: (body: string) => Promise }; }; @@ -1625,11 +1631,7 @@ describe("IssueDetail", () => { ]); const windowOpen = vi.spyOn(window, "open").mockImplementation(() => null); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { expect( @@ -1680,7 +1682,7 @@ describe("IssueDetail", () => { mockIssuesApi.listWorkProducts.mockReturnValue(history.promise); await act(async () => { - root.render(); + renderIssueView(); }); await waitForAssertion(() => { expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ @@ -1699,11 +1701,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockReturnValueOnce(issueRequest.promise); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); // The task response may need slow workspace/recovery enrichment. The @@ -1766,11 +1764,7 @@ describe("IssueDetail", () => { .mockClear() .mockResolvedValue({ ...issue, comment: createIssueComment() }); await act(async () => - root.render( - - - , - ), + renderIssueView(), ); await waitForAssertion(() => expect(mockIssueChatThreadRender).toHaveBeenCalled(), @@ -1857,11 +1851,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -1903,11 +1893,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -1925,11 +1911,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const content = container.querySelector( @@ -1946,11 +1928,7 @@ describe("IssueDetail", () => { mockPanelState.panelVisible = false; await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); expect( @@ -1969,11 +1947,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -2001,11 +1975,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(issue); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2064,11 +2034,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2094,11 +2060,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2117,11 +2079,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as @@ -2133,11 +2091,7 @@ describe("IssueDetail", () => { mockLocation.hash = "#work-product-1"; await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2153,11 +2107,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as @@ -2171,11 +2121,7 @@ describe("IssueDetail", () => { mockSetPanelVisible.mockClear(); mockLocation.hash = "#document-continuation-summary"; await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); expect(mockSetPanelVisible).not.toHaveBeenCalled(); await waitForAssertion(() => { @@ -2190,7 +2136,7 @@ describe("IssueDetail", () => { mockLocation.state = { from: "inbox" }; mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render(); + renderIssueView(); }); await waitForAssertion(() => expect(queryClient.getQueryData(queryKeys.issues.detail("PAP-1"))).toBeDefined()); const getCalls = mockIssuesApi.get.mock.calls.length; @@ -2213,7 +2159,7 @@ describe("IssueDetail", () => { it.each(["modifier", "new-tab", "download"])("preserves native document-link behavior for %s", async (kind) => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render(); + renderIssueView(); }); const link = document.createElement("a"); link.href = "#document-plan"; @@ -2232,11 +2178,7 @@ describe("IssueDetail", () => { mockLocation.hash = "#document-qa-evidence"; mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as @@ -2269,11 +2211,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2293,11 +2231,7 @@ describe("IssueDetail", () => { // Ending the deep link drops the pending request and re-arms the guard. mockLocation.hash = ""; await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { expect(mockClearPanelMaximizeRequest).toHaveBeenCalled(); @@ -2310,11 +2244,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2329,11 +2259,7 @@ describe("IssueDetail", () => { createIssue({ id: "issue-2", identifier: "PAP-2" }), ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2355,7 +2281,7 @@ describe("IssueDetail", () => { }], })); await act(async () => { - root.render(); + renderIssueView(); }); await waitForAssertion(() => { const props = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props; @@ -2370,7 +2296,7 @@ describe("IssueDetail", () => { mockPanelState.panelVisible = false; mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render(); + renderIssueView(); }); await waitForAssertion(() => { expect(queryClient.getQueryData(queryKeys.issues.attachments("PAP-1"))).toEqual([]); @@ -2412,11 +2338,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2439,11 +2361,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -2483,7 +2401,7 @@ describe("IssueDetail", () => { mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) => Promise.resolve(filters?.descendantOf === source.id ? [child] : filters?.createdFromIssueId === source.id ? [created] : []), ); - await act(async () => { root.render(); }); + await act(async () => { renderIssueView(); }); await flushReact(); await flushReact(); const taskProjection = () => mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props.tasksTab; @@ -2516,11 +2434,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2540,11 +2454,7 @@ describe("IssueDetail", () => { mockIssuesApi.list.mockResolvedValue([]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2644,11 +2554,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2671,11 +2577,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2725,11 +2627,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2772,7 +2670,7 @@ describe("IssueDetail", () => { if (outcome === "pending question") mockIssuesApi.listInteractions.mockResolvedValue([{ id: "question-1", kind: "ask_user_questions", status: "pending", payload: { version: 1, questions: [] } }]); if (fail) mockIssuesApi.resolveRecoveryAction.mockRejectedValue(new Error("The task is now paused.")); else mockIssuesApi.resolveRecoveryAction.mockResolvedValue({ issue: { ...issue, status: "todo", activeRecoveryAction: null }, recoveryAction: { ...issue.activeRecoveryAction, status: "resolved" } }); - await act(async () => root.render()); + await act(async () => renderIssueView()); await flushReact(); await flushReact(); const retry = Array.from(container.querySelectorAll("button")).find(b => b.textContent === "Retry agent"); expect(retry).toBeDefined(); @@ -2836,11 +2734,7 @@ describe("IssueDetail", () => { queryClient.setQueryData(unreadKey, [issue, otherIssue]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -2966,11 +2860,7 @@ describe("IssueDetail", () => { queryClient.setQueryData(mineKey, [issue]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3027,11 +2917,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3067,11 +2953,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3103,11 +2985,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3139,11 +3017,7 @@ describe("IssueDetail", () => { mockLocation.search = "?from=inbox&fromHref=%2Finbox%2Fmine"; await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3200,11 +3074,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3301,11 +3171,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3385,11 +3251,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3460,11 +3322,7 @@ describe("IssueDetail", () => { } await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3532,11 +3390,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3596,11 +3450,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3675,11 +3525,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3794,11 +3640,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3899,11 +3741,7 @@ describe("IssueDetail", () => { mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([activeRun]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -3969,11 +3807,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4103,11 +3937,7 @@ describe("IssueDetail", () => { mockIssuesApi.addComment.mockReturnValue(postedComment.promise); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4190,11 +4020,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4299,11 +4125,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4362,11 +4184,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4380,11 +4198,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4403,11 +4217,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4431,11 +4241,7 @@ describe("IssueDetail", () => { mockIssuesApi.getDocument.mockResolvedValue(null); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4464,11 +4270,7 @@ describe("IssueDetail", () => { mockIssuesApi.getDocument.mockResolvedValue(null); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4506,11 +4308,7 @@ describe("IssueDetail", () => { mockIssuesApi.getDocument.mockResolvedValue({ id: "doc-1", key: "plan" }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -4533,11 +4331,7 @@ describe("IssueDetail", () => { mockIssuesApi.getDocument.mockResolvedValue(null); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4581,11 +4375,7 @@ describe("IssueDetail", () => { mockIssuesApi.getDocument.mockResolvedValue({ id: "doc-1", key: "plan" }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -4602,11 +4392,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue({ originKind: "manual" })); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { @@ -4632,11 +4418,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -4718,11 +4500,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4824,11 +4602,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -4919,11 +4693,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5026,11 +4796,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5088,11 +4854,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5119,11 +4881,7 @@ describe("IssueDetail", () => { it("passes planning work mode to the issue chat thread", async () => { mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" })); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5176,11 +4934,7 @@ describe("IssueDetail", () => { mockIssuesApi.rejectInteraction.mockResolvedValue(rejectedInteraction); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5210,11 +4964,7 @@ describe("IssueDetail", () => { it("passes ask work mode to the issue chat thread", async () => { mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "ask" })); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5262,11 +5012,7 @@ describe("IssueDetail", () => { try { await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5318,11 +5064,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); @@ -5372,11 +5114,7 @@ describe("IssueDetail", () => { ]); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5409,11 +5147,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5437,11 +5171,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5470,11 +5200,7 @@ describe("IssueDetail", () => { mockIssuesApi.get.mockResolvedValue(createIssue()); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5517,11 +5243,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5670,11 +5392,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5770,11 +5488,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5850,11 +5564,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -5934,11 +5644,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as @@ -5989,11 +5695,7 @@ describe("IssueDetail", () => { mockIssuesApi.getQueuedComments.mockClear(); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await flushReact(); await flushReact(); @@ -6025,7 +5727,7 @@ describe("IssueDetail", () => { }); let rejectSteer!: (error: Error) => void; mockIssuesApi.steerQueuedComment.mockReturnValue(new Promise((_, reject) => { rejectSteer = reject; })); - await act(async () => { root.render(); }); + await act(async () => { renderIssueView(); }); type Props = { onSteerQueuedComment: (id: string, revision: string) => Promise; queuedCommentQueue: IssueQueuedCommentQueue | null }; let props!: Props; await waitForAssertion(() => { @@ -6090,11 +5792,7 @@ describe("IssueDetail", () => { mockIssuesApi.steerQueuedComment.mockResolvedValue(steeredQueue); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); let steer!: (commentId: string, revision: string) => Promise; @@ -6204,7 +5902,7 @@ describe("IssueDetail", () => { }); const delivery = createDeferred(); mockIssuesApi.interruptQueuedComments.mockReturnValue(delivery.promise); - await act(async () => { root.render(); }); + await act(async () => { renderIssueView(); }); type Props = { onInterruptQueued: (runId: string | null) => Promise; queuedCommentQueue: IssueQueuedCommentQueue | null; comments: Array<{ id: string; queueState?: string; conversationAnchorAt?: string }> }; let props!: Props; await waitForAssertion(() => { @@ -6245,7 +5943,7 @@ describe("IssueDetail", () => { mockIssuesApi.getQueuedComments.mockResolvedValue(createQueuedCommentQueue({ protocol: "legacy", steeringDisposition: "unsupported" })); const delivery = createDeferred(); mockIssuesApi.interruptQueuedComments.mockReturnValue(delivery.promise); - await act(async () => { root.render(); }); + await act(async () => { renderIssueView(); }); type Props = { onInterruptQueued: (runId: string | null) => Promise; comments: Array<{ id: string; queueState?: string }> }; let props!: Props; await waitForAssertion(() => { @@ -6282,11 +5980,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); await waitForAssertion(() => { const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as @@ -6348,11 +6042,7 @@ describe("IssueDetail", () => { }); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); let discard!: (commentId: string, revision: string) => Promise; @@ -6406,11 +6096,7 @@ describe("IssueDetail", () => { ); await act(async () => { - root.render( - - - , - ); + renderIssueView(); }); let discard!: (commentId: string, revision: string) => Promise; await waitForAssertion(() => { diff --git a/ui/src/pages/NewAgent.test-support.tsx b/ui/src/pages/NewAgent.test-support.tsx new file mode 100644 index 00000000000..bbd53fd9493 --- /dev/null +++ b/ui/src/pages/NewAgent.test-support.tsx @@ -0,0 +1,203 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, vi } from "vitest"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { queryKeys } from "@/lib/queryKeys"; +import { NewAgent } from "./NewAgent"; +import { ApiError } from "@/api/client"; + +const api = vi.hoisted(() => ({ + get: vi.fn(), + adapterModels: vi.fn(), + list: vi.fn(), + hire: vi.fn(), + testEnvironment: vi.fn(), + getAdapterAuthSignal: vi.fn(), + getClaudeOAuthTokenStatus: vi.fn(), +})); +const envApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn() })); +const settings = vi.hoisted(() => ({ + get: vi.fn(), + getExperimental: vi.fn(), + getGeneral: vi.fn(), +})); +const secrets = vi.hoisted(() => ({ + create: vi.fn(), + remove: vi.fn(), + list: vi.fn(), + listMyUserSecrets: vi.fn(), + createUserSecretDefinition: vi.fn(), + createMyUserSecret: vi.fn(), + rotateMyUserSecret: vi.fn(), + removeUserSecretDefinition: vi.fn(), +})); +const state = vi.hoisted(() => ({ + params: new URLSearchParams(), + adapters: [] as object[], + navigate: vi.fn(), + openNewIssue: vi.fn(), +})); +const managedApi = vi.hoisted(() => ({ + list: vi.fn(async () => ({ currentUserId: "user-1", canManageConnections: true, connections: [] })), + create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), + setDefault: vi.fn(async () => ({})), +})); +vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); +vi.mock("@/api/agents", () => ({ agentsApi: api })); +vi.mock("@/api/environments", () => ({ environmentsApi: envApi })); +vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings })); +vi.mock("@/api/secrets", () => ({ secretsApi: secrets })); +const adapterApi = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock("@/api/adapters", () => ({ adaptersApi: adapterApi })); +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), +})); +vi.mock("@/context/DialogContext", () => ({ + useDialogActions: () => ({ openNewIssue: state.openNewIssue }), +})); +vi.mock("@/lib/router", () => ({ + useNavigate: () => state.navigate, + useSearchParams: () => [state.params], +})); +vi.mock("@/components/AgentConfigForm", () => ({ + ModelDropdown: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => ( + onChange(e.target.value)} + /> + ), + AdapterLoginPanel: ({ onConnected }: { onConnected: (id: string) => void }) => ( + + ), +})); +vi.mock("@/components/onboarding/PillGuy", () => ({ PillGuy: () => null })); +vi.mock("motion/react", () => ({ + AnimatePresence: ({ children }: any) => children, + MotionConfig: ({ children }: any) => children, + motion: { + span: ({ children }: any) => {children}, + div: ({ children, initial, animate, exit, transition, ...rest }: any) => ( +
{children}
+ ), + }, +})); +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +let root: Root; +let container: HTMLDivElement; +let cache: QueryClient; +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} +async function click(text: string) { + const button = [...container.querySelectorAll("button")].find( + (b) => b.textContent?.trim() === text, + ); + expect(button, `Missing button ${text}`).toBeTruthy(); + await act(async () => button!.click()); + await settle(); +} +async function fill(label: string, value: string) { + const input = container.querySelector( + `[aria-label="${label}"]`, + ) as HTMLInputElement; + expect(input).toBeTruthy(); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +async function render(adapter = "pi_local", runnerProvider = "codex") { + state.params = new URLSearchParams({ + name: "Atlas", + adapterType: adapter, + runnerProvider, + }); + await renderCurrentParams(); +} +async function renderCurrentParams() { + await act(async () => + root.render( + + + , + ), + ); + await settle(); +} +async function connect(provider: string) { + await click(provider + "Subscription"); + await click("Connect"); +} +const pass = { + adapterType: "pi_local", + status: "pass", + checks: [ + { code: "hello_probe_passed", level: "info", message: "Model replied" }, + ], + testedAt: "2026-09-07T00:00:00Z", +}; +beforeEach(() => { + vi.clearAllMocks(); + adapterApi.list.mockImplementation(async () => state.adapters); + managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + cache = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + state.adapters = [ + "claude_local", + "codex_local", + "opencode_local", + "pi_local", + "paperclip_runner", "cursor_cloud", "cursor", "gemini_local", "kimi_local", "grok_local", "hermes_local", "hermes_gateway", + ].map((type) => ({ type, loaded: true, disabled: false })); + api.adapterModels.mockResolvedValue([]); + api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]); + api.getAdapterAuthSignal.mockResolvedValue({ status: "present" }); + api.getClaudeOAuthTokenStatus.mockRejectedValue(new ApiError("Not found", 404, null)); + api.testEnvironment.mockResolvedValue(pass); + api.hire.mockImplementation(async (_company, input) => ({ + agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" }, + })); + envApi.list.mockResolvedValue([ + { id: "local-1", name: "Local", driver: "local", config: {} }, + ]); + envApi.capabilities.mockResolvedValue({ sandboxProviders: {} }); + settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" }); + settings.getExperimental.mockResolvedValue({ enableNativeRunner: true }); + settings.getGeneral.mockResolvedValue({ executionMode: "any" }); + secrets.list.mockResolvedValue([]); + secrets.create.mockResolvedValue({ id: "org-secret-1" }); + secrets.remove.mockResolvedValue({ ok: true }); + secrets.listMyUserSecrets.mockResolvedValue([]); + secrets.createUserSecretDefinition.mockResolvedValue({ id: "definition-1" }); + secrets.createMyUserSecret.mockResolvedValue({ id: "secret-1" }); + secrets.removeUserSecretDefinition.mockResolvedValue({ ok: true }); +}); +afterEach(async () => { + await act(async () => root.unmount()); + cache.clear(); + container.remove(); +}); + +export { api, envApi, settings, secrets, state, managedApi, adapterApi, root, container, cache, settle, click, fill, render, connect, pass, renderCurrentParams }; diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index 80765ed25e5..983924ef71c 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -1,204 +1,13 @@ // @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 { TooltipProvider } from "@/components/ui/tooltip"; +import { describe, expect, it, vi } from "vitest"; import { queryKeys } from "@/lib/queryKeys"; -import { NewAgent } from "./NewAgent"; -import { ApiError } from "@/api/client"; +import { + api, envApi, settings, secrets, state, managedApi, adapterApi, + root, container, cache, settle, click, fill, render, connect, pass, + renderCurrentParams, +} from "./NewAgent.test-support"; -const api = vi.hoisted(() => ({ - get: vi.fn(), - adapterModels: vi.fn(), - list: vi.fn(), - hire: vi.fn(), - testEnvironment: vi.fn(), - getAdapterAuthSignal: vi.fn(), - getClaudeOAuthTokenStatus: vi.fn(), -})); -const envApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn() })); -const settings = vi.hoisted(() => ({ - get: vi.fn(), - getExperimental: vi.fn(), - getGeneral: vi.fn(), -})); -const secrets = vi.hoisted(() => ({ - create: vi.fn(), - remove: vi.fn(), - list: vi.fn(), - listMyUserSecrets: vi.fn(), - createUserSecretDefinition: vi.fn(), - createMyUserSecret: vi.fn(), - rotateMyUserSecret: vi.fn(), - removeUserSecretDefinition: vi.fn(), -})); -const state = vi.hoisted(() => ({ - params: new URLSearchParams(), - adapters: [] as object[], - navigate: vi.fn(), - openNewIssue: vi.fn(), -})); -const managedApi = vi.hoisted(() => ({ - list: vi.fn(async () => ({ currentUserId: "user-1", canManageConnections: true, connections: [] })), - create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), - setDefault: vi.fn(async () => ({})), -})); -vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); -vi.mock("@/api/agents", () => ({ agentsApi: api })); -vi.mock("@/api/environments", () => ({ environmentsApi: envApi })); -vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings })); -vi.mock("@/api/secrets", () => ({ secretsApi: secrets })); -const adapterApi = vi.hoisted(() => ({ list: vi.fn() })); -vi.mock("@/api/adapters", () => ({ adaptersApi: adapterApi })); -vi.mock("@/context/CompanyContext", () => ({ - useCompany: () => ({ selectedCompanyId: "company-1" }), -})); -vi.mock("../context/BreadcrumbContext", () => ({ - useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), -})); -vi.mock("@/context/DialogContext", () => ({ - useDialogActions: () => ({ openNewIssue: state.openNewIssue }), -})); -vi.mock("@/lib/router", () => ({ - useNavigate: () => state.navigate, - useSearchParams: () => [state.params], -})); -// Exercise API/persistence contracts with deterministic presentation primitives. -// The actual searchable dropdown and login panel are covered by their tests and browser verification. -vi.mock("@/components/AgentConfigForm", () => ({ - ModelDropdown: ({ - value, - onChange, - }: { - value: string; - onChange: (value: string) => void; - }) => ( - onChange(e.target.value)} - /> - ), - AdapterLoginPanel: ({ onConnected }: { onConnected: (id: string) => void }) => ( - - ), -})); -vi.mock("@/components/onboarding/PillGuy", () => ({ PillGuy: () => null })); -vi.mock("motion/react", () => ({ - AnimatePresence: ({ children }: any) => children, - MotionConfig: ({ children }: any) => children, - motion: { - span: ({ children }: any) => {children}, - div: ({ children, initial, animate, exit, transition, ...rest }: any) => ( -
{children}
- ), - }, -})); -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -let root: Root; -let container: HTMLDivElement; -let cache: QueryClient; -async function settle() { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); -} -async function click(text: string) { - const button = [...container.querySelectorAll("button")].find( - (b) => b.textContent?.trim() === text, - ); - expect(button, `Missing button ${text}`).toBeTruthy(); - await act(async () => button!.click()); - await settle(); -} -async function fill(label: string, value: string) { - const input = container.querySelector( - `[aria-label="${label}"]`, - ) as HTMLInputElement; - expect(input).toBeTruthy(); - await act(async () => { - Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "value", - )!.set!.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); - }); -} -async function render(adapter = "pi_local", runnerProvider = "codex") { - state.params = new URLSearchParams({ - name: "Atlas", - adapterType: adapter, - runnerProvider, - }); - await act(async () => - root.render( - - - , - ), - ); - await settle(); -} -async function connect(provider: string) { - await click(provider + "Subscription"); - await click("Connect"); -} -const pass = { - adapterType: "pi_local", - status: "pass", - checks: [ - { code: "hello_probe_passed", level: "info", message: "Model replied" }, - ], - testedAt: "2026-09-07T00:00:00Z", -}; -beforeEach(() => { - vi.clearAllMocks(); - adapterApi.list.mockImplementation(async () => state.adapters); - managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - cache = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - state.adapters = [ - "claude_local", - "codex_local", - "opencode_local", - "pi_local", - "paperclip_runner", "cursor_cloud", "cursor", "gemini_local", "kimi_local", "grok_local", "hermes_local", "hermes_gateway", - ].map((type) => ({ type, loaded: true, disabled: false })); - api.adapterModels.mockResolvedValue([]); - api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]); - api.getAdapterAuthSignal.mockResolvedValue({ status: "present" }); - api.getClaudeOAuthTokenStatus.mockRejectedValue(new ApiError("Not found", 404, null)); - api.testEnvironment.mockResolvedValue(pass); - api.hire.mockImplementation(async (_company, input) => ({ - agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" }, - })); - envApi.list.mockResolvedValue([ - { id: "local-1", name: "Local", driver: "local", config: {} }, - ]); - envApi.capabilities.mockResolvedValue({ sandboxProviders: {} }); - settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" }); - settings.getExperimental.mockResolvedValue({ enableNativeRunner: true }); - settings.getGeneral.mockResolvedValue({ executionMode: "any" }); - secrets.list.mockResolvedValue([]); - secrets.create.mockResolvedValue({ id: "org-secret-1" }); - secrets.remove.mockResolvedValue({ ok: true }); - secrets.listMyUserSecrets.mockResolvedValue([]); - secrets.createUserSecretDefinition.mockResolvedValue({ id: "definition-1" }); - secrets.createMyUserSecret.mockResolvedValue({ id: "secret-1" }); - secrets.removeUserSecretDefinition.mockResolvedValue({ ok: true }); -}); -afterEach(async () => { - await act(async () => root.unmount()); - cache.clear(); - container.remove(); -}); describe("New agent setup", () => { it("waits for the current instance default instead of testing cached SSH discovery", async () => { let finish!: (value: object) => void; @@ -663,14 +472,7 @@ describe("New agent setup", () => { adapterType: "pi_local", createdAgentId: "saved-agent", }); - await act(async () => - root.render( - - - , - ), - ); - await settle(); + await renderCurrentParams(); expect(container.textContent).toContain("Your agent is ready"); expect(container.textContent).not.toContain("Finish setup"); expect(api.hire).not.toHaveBeenCalled(); diff --git a/ui/src/pages/TeamCatalog.fixtures.ts b/ui/src/pages/TeamCatalog.fixtures.ts index c3f93191f85..511dd3b40b2 100644 --- a/ui/src/pages/TeamCatalog.fixtures.ts +++ b/ui/src/pages/TeamCatalog.fixtures.ts @@ -7,7 +7,7 @@ import type { // --------------------------------------------------------------------------- // Shared Team Catalog fixtures. // -// Used by both the Storybook stories (ui/storybook/stories/team-catalog.stories.tsx) +// Used by both the Storybook stories (ui/storybook/stories/teams/team-catalog.stories.tsx) // and the in-app /design-guide showcase so the two surfaces stay in sync. // --------------------------------------------------------------------------- diff --git a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx index dedf6127417..adf87af5089 100644 --- a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx +++ b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx @@ -503,23 +503,15 @@ describe("chat setup and identity-link clipboard actions", () => { expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("4Send a message"); }); - it("keeps a terminated agent’s saved avatar available in settings", async () => { - mocks.tab = "settings"; - mocks.listAgents.mockResolvedValue([]); - mocks.getAgent.mockResolvedValue({ id: "agent-a", name: "Maya", status: "terminated", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "orchid-peach" } }); - await render("slack", true); - expect(container.querySelector('a[download]')?.getAttribute("href")).toBe("/api/agent-avatars/cap-v1/orchid-peach/rest.png?size=512&scale=1&background=paperclip-dark"); - expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a"); - }); - - it("keeps avatar download available in connector settings", async () => { + it("keeps settings focused on channel access and the test-message action", async () => { mocks.tab = "settings"; await render("slack", true); - const section = container.querySelector('section[aria-label="Slack avatar"]')!; - expect(section.textContent).toContain("Download avatar"); - expect(section.querySelector('a[download]')?.getAttribute("href")).toContain("/cherry-pop/rest.png?size=512&scale=1"); - expect(section.querySelector("details")?.open).toBe(false); - expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a"); + expect(container.textContent).toContain("Send me a test message"); + expect(container.textContent).toContain("Open Slack configuration"); + expect(container.textContent).not.toContain("Message in a channel"); + expect(container.textContent).not.toContain("Agent avatar"); + expect(container.textContent).not.toContain("Manage action permissions"); + expect(container.querySelector('a[download]')).toBeNull(); }); it("discovers a newly invited, enabled Slack channel while settings stays open", async () => { diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index d26e6b75416..dd075cf795e 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -1,14 +1,13 @@ +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; +import { resolveAgentAppearance } from "@paperclipai/shared"; +import { SlackTestMessageButton } from "./SlackTestMessageButton"; +import { AgentAvatar } from "@/components/AgentAvatar"; import { Identity } from "@/components/Identity"; import { SlackSetupAdvanced } from "./SlackAppDetails"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { SlackToolsSettings, SlackSearchAccess } from "./SlackToolSettings"; -import { defaultSlackAppName } from "./slack-app-name"; import { ChatCommunicationInstructions } from "./ChatCommunicationInstructions"; -import { SlackAvatarSettings } from "./SlackAvatarStep"; import { agentsApi } from "@/api/agents"; -import { agentAvatarUrl } from "@/lib/agent-avatar-url"; -import { resolveAgentAppearance } from "@paperclipai/shared"; -import { AgentAvatar } from "@/components/AgentAvatar"; import { ChatConversationList } from "./ChatConversationList"; import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement"; import { GitHubBotMention, GitHubAppIdentityActions } from "./GitHubAppIdentity"; @@ -315,8 +314,9 @@ export function ChatEndpointDetail() { endpoint.setup?.step !== "complete" && !(endpoint.provider === "slack" && endpoint.setup?.step === "test") && ["draft", "verifying", "attention", "revoked"].includes(endpoint.status); - const slackUrl = endpoint.provider === "slack" && endpoint.providerAccountId && endpoint.botExternalId - ? `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/user/${encodeURIComponent(endpoint.botExternalId)}` + const slackAppId = endpoint.setup?.slackRegistration?.appId; + const slackUrl = endpoint.provider === "slack" + ? slackAppId ? `https://api.slack.com/apps/${encodeURIComponent(slackAppId)}/oauth` : "https://api.slack.com/apps" : null; return ( @@ -347,8 +347,8 @@ export function ChatEndpointDetail() { )} -
- {slackUrl && } +
+ {slackUrl && } {setupIncomplete ? (
+ {endpoint.provider === "slack" && } {endpoint.provider === "github" && } @@ -435,13 +436,6 @@ function Settings({ }) { const queryClient = useQueryClient(); const { pushToast } = useToast(); - const [messageCopied, setMessageCopied] = useState(false); - const avatarAgent = useQuery({ - queryKey: queryKeys.agents.detail(endpoint.assignedAgentId), - queryFn: () => agentsApi.get(endpoint.assignedAgentId, endpoint.companyId), - enabled: endpoint.provider === "slack", - }); - const mentionMessage = `@${(endpoint.botUsername ?? endpoint.botLabel ?? endpoint.assignedAgentName).replace(/^@/, "")} you there?`; const resourcesQuery = useQuery({ queryKey: queryKeys.chatEndpoints.resources(endpointId), queryFn: () => chatEndpointsApi.listResources(endpointId), @@ -512,7 +506,7 @@ function Settings({ No destinations yet.

) : ( -
+
{destinationResources.map((resource) => (
@@ -595,27 +589,7 @@ function Settings({ queryClient.setQueryData(queryKeys.chatEndpoints.detail(endpointId), next); }} />} - {endpoint.provider === "slack" && ( -
-

Invite the bot to a channel, then mention it to start a conversation.

-
- {mentionMessage} - -
-
- )} - {endpoint.provider === "slack" && - {avatarAgent.isPending ?

Loading agent avatar…

- : avatarAgent.isError ?

Couldn’t load the agent’s avatar.

- : } -
} - {endpoint.provider === "slack" && } + {endpoint.provider === "slack" && } ); } @@ -634,7 +608,7 @@ function SettingToggle({ onChange: (value: boolean) => void; }) { return ( -
+

{label}

{detail &&

{detail}

} diff --git a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx index fae0c99bb7f..a8d45b008ac 100644 --- a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx @@ -1,6 +1,7 @@ import { buildSlackAppManifest, defaultSlackAppConfiguration } from "@paperclipai/shared"; import { SlackAppDetails, SlackSetupAdvanced } from "./SlackAppDetails"; -import { SlackAutomaticSetup } from "./SlackAutomaticSetup"; +import { SlackSetup } from "./slack/SlackSetup"; +import { isProvisionedSlackSetup, slackSetupState } from "./slack/setup-state"; import { defaultSlackAppName } from "./slack-app-name"; import { GitHubChatSetup } from "./GitHubChatSetup"; import { GitHubAgentTrustWarning } from "@/components/GitHubAgentTrustWarning"; @@ -105,10 +106,11 @@ export function isChatEndpointRepairing( ); } -export function ChatConnectionPurpose({ provider, onChat, onTools }: { +export function ChatConnectionPurpose({ provider, onChat, onTools, slackLabels }: { provider: ChatProvider; onChat: () => void; onTools: () => void; + slackLabels?: string[]; }) { const { setBreadcrumbs } = useBreadcrumbs(); useEffect(() => { @@ -117,7 +119,7 @@ export function ChatConnectionPurpose({ provider, onChat, onTools }: { }, [setBreadcrumbs]); return (
- +

Choose how to connect

@@ -192,6 +194,7 @@ function ChatSdkEndpointSetup() { const [viewedStep, setViewedStep] = useState(null); const [slackIdentityReady, setSlackIdentityReady] = useState(false); const [automaticBusy, setAutomaticBusy] = useState(false); + const [managedAutoStart, setManagedAutoStart] = useState(Boolean(resumeEndpointId)); const [slackDraft, setSlackDraft] = useState<{ agentId: string; app: SlackAppConfiguration } | null>(null); const [endpoint, setEndpoint] = useState(null); const [credentials, setCredentials] = useState>({}); @@ -220,7 +223,7 @@ function ChatSdkEndpointSetup() { useEffect(() => { if (!resumeQuery.data) return; setEndpoint(current => { - if (current?.setup?.slackSetupMethod === "automatic" && !current.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null); + if (isProvisionedSlackSetup(current?.setup?.slackSetupMethod) && !current?.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null); return resumeQuery.data; }); setAgentId(resumeQuery.data.assignedAgentId); @@ -273,7 +276,8 @@ function ChatSdkEndpointSetup() { next: ChatEndpoint, onlyIfStillVisible = false, ) => { - if (next.setup?.slackSetupMethod === "automatic" && next.setup.slackRegistration?.status === "configured" + if (next.setup?.slackSetupMethod === "managed" && endpoint && endpoint.setup?.slackSetupMethod !== "managed") setManagedAutoStart(true); + if (isProvisionedSlackSetup(next.setup?.slackSetupMethod) && next.setup?.slackRegistration?.status === "configured" && endpoint?.setup?.slackRegistration?.status !== "configured") setViewedStep(null); setEndpoint((visible) => onlyIfStillVisible && visible?.id !== next.id ? visible : next, @@ -287,21 +291,31 @@ function ChatSdkEndpointSetup() { ); } }; + const slackChoices = useQuery({ + queryKey: ["slack-setup-options", selectedCompanyId], + queryFn: () => chatEndpointsApi.slackSetupOptions(selectedCompanyId!), + enabled: provider === "slack" && Boolean(selectedCompanyId), + retry: false, + }); const createEndpoint = useMutation({ - mutationFn: () => - chatEndpointsApi.create(selectedCompanyId!, { + mutationFn: ({ ownApp = false }: { ownApp?: boolean; exit?: boolean }) => { + if (provider === "slack" && !slackChoices.data) throw new Error("Slack setup is still loading. Try again."); + return chatEndpointsApi.create(selectedCompanyId!, { provider: provider!, assignedAgentId: agentId, - ...(provider === "slack" ? { slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}), - }), - onSuccess: (next) => { - setViewedStep(null); + ...(provider === "slack" ? { slackSetupMethod: ownApp ? "automatic" : slackChoices.data!.defaultMethod, slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}), + }); + }, + onSuccess: (next, { exit }) => { + setManagedAutoStart(!exit); + setViewedStep(exit ? 0 : null); syncEndpointSnapshot(next); if (next.provider === "imessage-photon" || next.provider === "slack") { const resumed = new URLSearchParams(params); resumed.set("resume", next.id); setParams(resumed, { replace: true }); } + if (exit) navigate("/apps"); }, onError: (error) => pushToast({ @@ -324,13 +338,22 @@ function ChatSdkEndpointSetup() { if (exit && provider !== "slack") { navigate("/apps"); return; } if (!endpoint) { if (!agentId) { if (exit) navigate("/apps"); return; } - createEndpoint.mutate(undefined, { onSuccess: () => { if (exit) navigate("/apps"); } }); + createEndpoint.mutate({ exit }); return; } try { if (provider === "slack") await persistSlackDetails(); } catch { return; } - if (exit) navigate("/apps"); else setViewedStep(1); + if (exit) navigate("/apps"); else { setManagedAutoStart(true); setViewedStep(null); } }; + const useOwnSlackApp = useMutation({ + mutationFn: async () => { + if (!endpoint) return createEndpoint.mutateAsync({ ownApp: true }); + await persistSlackDetails(); + return chatEndpointsApi.update(endpoint.id, { slackSetupMethod: "automatic" }); + }, + onSuccess: next => { setManagedAutoStart(false); syncEndpointSnapshot(next); setViewedStep(1); }, + onError: () => pushToast({ title: "Couldn't switch Slack setup", body: "Try again.", tone: "error" }), + }); const setupAction = useMutation({ mutationFn: async ({ action, @@ -454,21 +477,14 @@ function ChatSdkEndpointSetup() { reconnectRequested, ); const isSlack = provider === "slack"; - const automaticSlack = isSlack && (!endpoint || endpoint.setup?.slackSetupMethod === "automatic"); - const automaticSlackComplete = automaticSlack && (endpoint?.setup?.step === "test" || endpoint?.setup?.step === "complete"); - const tryStep = isSlack ? automaticSlack ? 3 : 5 : 2; - const availableStep = endpoint - ? automaticSlack && endpoint.setup?.slackRegistration?.status === "configured" && endpoint.setup.slackAccount?.status !== "linked" && endpoint.setup.step !== "complete" ? 2 - : !repairing && - (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") - ? isSlack && endpoint.setup?.step !== "complete" - ? !automaticSlack && !slackIdentityReady ? 4 : tryStep - : tryStep - : automaticSlack && endpoint.setup?.slackRegistration?.status === "credentials_saved" ? 2 - : isSlack && endpoint.providerAccountId && !repairing ? 3 - : isSlack && (slackCredentialsReady || repairing || endpoint.setup?.slackSetupMethod === "existing" || automaticSlack && endpoint.setup?.slackRegistration?.appId) ? 2 : 1 - : 0; + const slackNavigation = slackSetupState({ endpoint, repairing, credentialsReady: slackCredentialsReady, identityReady: slackIdentityReady, defaultMethod: slackChoices.data?.defaultMethod }); + const automaticSlack = isSlack && slackNavigation.provisioned; + const automaticSlackComplete = automaticSlack && slackNavigation.complete; + const tryStep = isSlack ? slackNavigation.finalStage : 2; + const availableStep = isSlack ? slackNavigation.availableStage : !endpoint ? 0 + : !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") ? tryStep : 1; const step = Math.min(viewedStep ?? availableStep, availableStep); + const agentStepBusy = automaticBusy || createEndpoint.isPending || saveSlackDetails.isPending || useOwnSlackApp.isPending; const slackVerificationQuery = useQuery({ queryKey: ["chat-endpoint-slack-webhook-verification", endpoint?.id], queryFn: () => chatEndpointsApi.get(endpoint!.id), @@ -507,17 +523,17 @@ function ChatSdkEndpointSetup() { ); if (purpose === "choice") { - return setPurpose("chat")} onTools={() => navigate(toolHref)} />; + return setPurpose("chat")} onTools={() => navigate(toolHref)} />; } return (

setViewedStep(isSlack ? slackNavigation.toStage(value) : value)} />
{step === 0 ? ( @@ -541,15 +557,23 @@ function ChatSdkEndpointSetup() { emptyMessage="No active agents are available." />} {isSlack && (selectedAgent || endpoint) && - setSlackDraft({ agentId, app })} onBlur={() => void persistSlackDetails().catch(() => {})} /> {saveSlackDetails.isError &&

Couldn't save the Slack app details. Try again before continuing.

} + {slackChoices.data?.defaultMethod === "managed" && (!endpoint || endpoint.setup?.slackSetupMethod === "managed") && !endpoint?.setup?.slackRegistration && }
} + {isSlack && !endpoint && !slackChoices.data && (slackChoices.isError + ?
Couldn't load Slack setup.
+ :

Loading Slack…

)} {provider === "github" && } - void continueFromAgent(true)}> + void continueFromAgent(true)}>
)}
-

{slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}

+

{endpoint.setup?.slackSetupMethod === "managed" ? "Connect to Slack" : slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}

{repairing && !automaticSlack && (

Reconnect verifies or replaces credentials for this same Slack app. It does not reinstall the app or change its workspace or channel membership. Leave credentials blank to reuse the saved values. @@ -1628,8 +1655,10 @@ function ProviderConnectStep({

}
- {automaticSlack && {}} onBusy={onAutomaticBusy} onSaved={onEndpointSaved} onContinue={onSlackAppCreated} diff --git a/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx b/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx index 3fe861aaedb..ff342c014a4 100644 --- a/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx +++ b/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx @@ -1,183 +1 @@ -import { useEffect, useRef, useState } from "react"; -import { ExternalLink, Loader2 } from "lucide-react"; -import { slackRegistrationErrorMessage } from "@paperclipai/shared"; -import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; -import { SetupWizardFooter } from "@/components/SetupWizard"; -import { sanitizedSetupErrorMessage } from "./chat-setup-error"; -import { SlackSetupAdvanced } from "./SlackAppDetails"; - -/** Uses ordinary request-local state: configuration tokens never enter a query/mutation cache. */ -export function SlackAutomaticSetup({ endpoint, stage, disabled, saveDetails, onSaved, onBusy, - onManual, onContinue, onSaveExit }: { - endpoint: ChatEndpoint; - stage: "app" | "credentials"; - disabled: boolean; - saveDetails: () => Promise; - onSaved: (endpoint: ChatEndpoint) => void; - onBusy: (busy: boolean) => void; - onManual: (existing: boolean) => Promise; - onContinue: () => void; - onSaveExit: () => void; -}) { - const [configurationToken, setConfigurationToken] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [checkedNoApp, setCheckedNoApp] = useState(false); - const requestId = useRef(crypto.randomUUID()); - const inFlight = useRef(false); - const pendingWindow = useRef(null); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - pendingWindow.current?.close(); - pendingWindow.current = null; - }; - }, []); - const registration = endpoint.setup?.slackRegistration; - const uncertain = registration?.status === "uncertain"; - const creating = registration?.status === "creating"; - const created = Boolean(registration?.appId); - const configurationPending = registration?.errorCode === "slack_manifest_update_pending"; - - async function run(action: () => Promise) { - if (inFlight.current) return; - inFlight.current = true; - setBusy(true); - onBusy(true); - setError(null); - try { await action(); } - catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); } - finally { - setConfigurationToken(""); - setBusy(false); - onBusy(false); - inFlight.current = false; - } - } - - async function create() { - // Reserve the window during the click so async app creation cannot trigger a popup blocker. - const installWindow = window.open("about:blank", "_blank"); - if (installWindow) { - installWindow.opener = null; - installWindow.document.title = "Preparing Slack installation"; - installWindow.document.body.textContent = "Preparing your Slack installation…"; - pendingWindow.current = installWindow; - } - let token = configurationToken.trim(); - setConfigurationToken(""); - try { - let next: ChatEndpoint; - try { - if (!created) await saveDetails(); - if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID(); - next = await chatEndpointsApi.createSlackApp(endpoint.id, { - requestId: requestId.current, - credentials: { configurationToken: token }, - ...(checkedNoApp ? { confirmedNoAppCreated: true } : {}), - }); - } catch (failure) { - // A response can be lost after Slack creates the app. Only saved state can advance. - const current = await chatEndpointsApi.get(endpoint.id).catch(() => null); - if (current && mounted.current) onSaved(current); - if (!current?.setup?.slackRegistration?.appId) { - throw new Error(sanitizedSetupErrorMessage(failure, { configurationToken: token })); - } - next = current; - } - token = ""; - if (!mounted.current) return; - setCheckedNoApp(false); - onSaved(next); - const saved = next.setup?.slackRegistration; - if (!saved?.appId) return; - onContinue(); - // Pending/uncertain configuration and existing installations must not start OAuth. - if (saved.status !== "install" || saved.errorCode) return; - if (!installWindow || installWindow.closed) { - setError("Select Install in Slack to open the installation page."); - return; - } - const result = await chatEndpointsApi.installSlackApp(endpoint.id); - if (!mounted.current) return; - if (installWindow.closed) { - setError("Select Install in Slack to reopen the installation page."); - return; - } - installWindow.location.replace(result.authorizationUrl); - pendingWindow.current = null; - } finally { - token = ""; - pendingWindow.current?.close(); - pendingWindow.current = null; - } - } - - async function authorize() { - const result = await chatEndpointsApi.installSlackApp(endpoint.id); - // Current-tab navigation avoids popup blockers. The callback resumes this saved draft. - window.location.assign(result.authorizationUrl); - } - - return
- {error || registration?.errorCode ?

- {error ?? slackRegistrationErrorMessage(registration!.errorCode!)} -

: null} - {(stage === "app" && !created && !creating || configurationPending) && <> - {uncertain &&
- Open Slack app settings - -
} - {(!uncertain || checkedNoApp) &&
- -
-
    -
  1. In Slack app settings, find Your App Configuration Tokens and choose Generate Token.
  2. -
  3. Select the workspace where you want to install your bot.
  4. -
  5. Copy the Access Token and paste it below.
  6. -
-

Paperclip uses this token to create your app

-
- - setConfigurationToken(event.target.value)} - aria-describedby="slack-configuration-token-help" /> -
} - } - {creating &&

Creating your Slack app. You can return to this saved setup.

} - {created &&

- {endpoint.setup?.slackApp?.appName ?? "Your Slack app"} is created. - {stage === "credentials" && " Approve installation with your Slack account. Paperclip will link it to your signed-in Paperclip account and send you a welcome DM."} -

} - {stage === "credentials" && registration?.status === "credentials_saved" &&
-

Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.

- -
} - {stage === "app" &&
- - -
} - - {configurationPending ? : stage === "app" ? : registration?.status === "credentials_saved" ? : } - -
; -} +export { OwnSlackAppSetup as SlackAutomaticSetup } from "./slack/OwnSlackAppSetup"; diff --git a/ui/src/pages/apps/chat/SlackSetupPrompt.tsx b/ui/src/pages/apps/chat/SlackSetupPrompt.tsx index b9256c27d59..2cc0568ea77 100644 --- a/ui/src/pages/apps/chat/SlackSetupPrompt.tsx +++ b/ui/src/pages/apps/chat/SlackSetupPrompt.tsx @@ -12,19 +12,21 @@ Ask me to take over for login, two-factor authentication, CAPTCHA, workspace-adm 2. Check public HTTPS before creating the app. Slack must be able to reach Paperclip's generated webhook URL over publicly reachable HTTPS with a valid certificate. Paperclip Cloud includes HTTPS. A private Tailscale Serve address alone is not reachable by Slack; self-hosting needs an authorized public ingress, such as Tailscale Funnel or a reverse proxy with a trusted certificate. Follow the warning's Learn more link if needed. Use the callback URL generated by this connection, including its full path. Check that it uses the intended public instance hostname or an explicitly configured webhook ingress, not localhost, an internal service address, or an old pool hostname. If it is wrong or unreachable, resolve the instance configuration before registration; do not invent a callback path, disable signature verification, or bypass Cloud authentication protections. -3. Create Slack app automatically. Review Slack app name, Bot display name, and Slash command under Advanced on Choose agent in Paperclip. Keep the generated defaults unless I requested other names. Use the automatic setup path for a new bot. Ask me to open Slack's Your App Configuration Tokens > Generate Token, select the agreed workspace, and enter the temporary configuration token directly into Paperclip's App configuration access token password field. Pause while I do that. Never read, copy, reveal, screenshot, or include that token in your prompt or tool output. Do not request its refresh token. Click Create Slack app after I enter it. Paperclip creates the app, saves its signing secret, and uploads the assigned agent's avatar. Avatar upload has no wizard step; if it fails, finish setup and optionally use Settings > Agent avatar for manual upload later. Do not recreate the app or request another configuration token for an optional avatar. Its saved state is the evidence of creation; a click or navigation is not proof. Resume the matching draft after refresh or Save & exit. If creation is uncertain, open Slack app settings and check the agreed workspace for the exact app before continuing. Never retry creation automatically. Only confirm that no app was created after I explicitly confirm that check, or recover the existing app on this same draft. No Slack CLI is required. +3. Use the saved Slack setup method. For a new connection, keep managed setup when it is the instance default. Continue on Choose agent opens Slack authorization or starts installation automatically; there is no separate Add to Slack screen. To explicitly use your own app, open Advanced on Choose agent and choose Use your own app below Slash command. Select only a workspace authorized by my own Paperclip account, or let me complete the manager authorization in Slack; pause for login and administrator consent. Reuse my saved workspace authorization for additional agents. Paperclip creates and installs the child app, uploads its avatar, and links my verified Slack identity. Continue within the same two-stop wizard: Choose agent, Connect. If Slack requires child-app consent or approval, complete that for the saved app and resume. Never switch methods after creation dispatch, migrate an existing bot, read manager credentials, or reuse a teammate's grant. If managed setup becomes unavailable, preserve the draft and report its recovery action. Use your own app only when selected or when managed setup is unavailable before creation. + +For the own-app path, create Slack app automatically. Review Slack app name, Bot display name, and Slash command under Advanced on Choose agent in Paperclip. Keep the generated defaults unless I requested other names. Use the automatic setup path for a new bot. Ask me to open Slack's Your App Configuration Tokens > Generate Token, select the agreed workspace, and enter the temporary configuration token directly into Paperclip's App configuration access token password field. Pause while I do that. Never read, copy, reveal, screenshot, or include that token in your prompt or tool output. Do not request its refresh token. Click Create Slack app after I enter it. Paperclip creates the app, saves its signing secret, and uploads the assigned agent's avatar. Avatar upload has no wizard step; if it fails, finish setup and optionally use Slack app settings > Basic Information to set its app icon later. Do not recreate the app or request another configuration token for an optional avatar. Its saved state is the evidence of creation; a click or navigation is not proof. Resume the matching draft after refresh or Save & exit. If creation is uncertain, open Slack app settings and check the agreed workspace for the exact app before continuing. Never retry creation automatically. Only confirm that no app was created after I explicitly confirm that check, or recover the existing app on this same draft. No Slack CLI is required. 4. Install Slack app. Successful creation and configuration advance Paperclip to Install Slack app and open Slack consent in a new window automatically. Use Install in Slack if that window was blocked or closed. Check the workspace and approve installation within the agreed scope. Pause for required login, ownership decisions, or administrator approval. Return to the saved wizard through Slack's OAuth redirect. Paperclip obtains and saves the bot token itself; do not locate, copy, or request the bot token or signing secret on the automatic path. Wait for Paperclip to report saved credentials. The fourth screen, Send a message to your agent, becomes Success automatically after Paperclip receives qualifying connection evidence. There is no separate Try it screen. If approval is declined or pending, preserve the app and resume its installation later without another configuration token. If authorization expires, start a fresh installation of this same app. If credentials are saved but connecting fails, use Retry connecting. If Slack access changed or the token was revoked, use Authorize in Slack again for this same app. Do not recreate the app. Installation automatically links the Slack account returned by OAuth to my signed-in Paperclip account and sends a welcome DM. Have me approve with my own Slack account; do not authorize with your account or a teammate’s account. There is no separate personal-linking step on the automatic path. Existing/conflicting/revoked links are managed in the connection’s Access settings later. Never overwrite them as a shortcut. Manual fallback only when selected or needed for recovery: choose Create manually or Use an existing app on the creation step. For manual creation, use Paperclip's generated manifest, select the agreed workspace in Slack, and install that exact app. Preserve all scopes, bot events, slash command, interactivity, and signed HTTP callbacks. For an existing app, reconcile its configuration with this connection's manifest and generated URLs. Have me enter the Bot User OAuth Token (OAuth & Permissions, xoxb- prefix) and Signing Secret (Basic Information > App Credentials) directly into the matching Paperclip fields, or use safe clipboard transfer only as described above. The Signing Secret is not the Client Secret, Verification Token, or app-level token. Click Connect Slack app. On a resumed manual setup, leave credential fields blank to keep saved values. Never use Socket Mode as a shortcut. -5. On the automatic path, use Send a message to your agent and its Open Slack link. If authorized, send a short message. Paperclip accepts an authenticated message event from the saved app/workspace as connection evidence and replaces this screen with Success. The welcome event may complete the check first. If Success appears, skip the Slack settings visit even if Slack's Request URL still says unverified. If Paperclip keeps waiting, expand Didn’t work? for Request URL instructions. On the manual path, open the same Slack app's Event Subscriptions. Make sure events are enabled. Beside the manifest's prefilled Request URL, click Retry if needed, wait for Slack to display Verified, and save changes when offered. Verification can fail before Paperclip has the credentials, so retry now. Return to Paperclip and wait for its verified state and automatic advancement, or Continue when available. If verification fails, inspect the specific error and the wizard's Troubleshooting URL: check public reachability, the correct app/workspace and signing secret, and the full generated callback URL. Also confirm the generated Slash Commands request URL and Interactivity & Shortcuts request URL were retained if those features fail. Use the manifest's values for each surface; do not assume verifying events proves every callback works. Do not recreate the app or change arbitrary URLs to hide a failed check. +5. On either provisioned path, use Send a message to your agent and its Open Slack link. If authorized, send a short message. Paperclip accepts an authenticated message event from the saved app/workspace as connection evidence and replaces this screen with Success. The welcome event may complete the check first. If Success appears, skip the Slack settings visit even if Slack's Request URL still says unverified. If Paperclip keeps waiting, expand Didn’t work? for Request URL instructions. On the manual path, open the same Slack app's Event Subscriptions. Make sure events are enabled. Beside the manifest's prefilled Request URL, click Retry if needed, wait for Slack to display Verified, and save changes when offered. Verification can fail before Paperclip has the credentials, so retry now. Return to Paperclip and wait for its verified state and automatic advancement, or Continue when available. If verification fails, inspect the specific error and the wizard's Troubleshooting URL: check public reachability, the correct app/workspace and signing secret, and the full generated callback URL. Also confirm the generated Slash Commands request URL and Interactivity & Shortcuts request URL were retained if those features fail. Use the manifest's values for each surface; do not assume verifying events proves every callback works. Do not recreate the app or change arbitrary URLs to hide a failed check. 6. Manual/existing-app paths only: connect your Slack account. Copy the exact command shown by Paperclip, consisting of this app's saved slash command followed by connect. In the correct Slack workspace, send it as my signed-in Slack user. This discovers identity without starting agent work. Return to Paperclip and wait for that identity to appear. Compare the Slack account/workspace and the target Paperclip user. Only select the ownership confirmation for that identity when they are verified as my accounts; its visible label is This is my Slack account and browser tools may identify it as Link to my Paperclip account. Ask me if ownership is ambiguous. Never link a teammate's account to mine. Confirm Linked to you and use Continue to message test. Keep unlinked-person access off unless I explicitly requested a different policy. On these manual paths installing the bot and linking a person are separate steps; future work uses that linked person's Paperclip permissions. 7. Prepare the agreed destination. If using a channel, invite/add this bot to that channel through Slack's UI if authorized. In the connection's Settings, inspect Where this agent can work. Use its Refresh action if available, or reload the page after the bot invitation if the channel is missing. Inspect Allowed Channels and make sure the intended test channel is enabled; newly invited channels start enabled, while an explicitly disabled channel stays off until a person re-enables it. Do not invite the bot to unrelated channels, enable every channel, or broaden anyone's access just to get a test to pass. For a DM, check the connection permits direct messages and use my conversation with this bot. Return to the saved wizard if you visited Settings. Reading access and allowed response destinations are distinct; seeing a channel in Slack does not prove the bot is allowed to reply there. -8. Optional live qualification when explicitly requested: on the automatic path, the wizard ends at Success and Done, with no additional test screen. Open the bot's DM directly if a conversation test was requested. The welcome DM and connection check alone do not prove an agent response. For a channel test or manual setup, send the wizard's suggested short message, for example @ you there? Find the bot belonging to this Slack app in Slack's mention suggestions and select it. Slack's actual display name can differ from Paperclip's suggested username: for example, Paperclip may suggest @cedarqa while Slack shows cedar-qa. Preserve Slack's displayed punctuation and select the real bot; pasting plain text that resembles a mention may not notify it. Use the configured bot, not a literal example. For a channel test, use a mention rather than the slash connect command. Wait for a real response in the originating Slack thread. Open the connection's Conversations page, follow the matching Open task link, and verify that the assigned agent's run completed successfully; the agent's Runs page provides the execution result if the Slack reply has no task link. Send one short follow-up in the same thread without another mention and confirm continuity and a single delivered answer. Do not claim success from a typing indicator, a verified webhook, the wizard's checkmark, or a manually posted substitute reply. If a run stops or no answer arrives, inspect the task/run and connection Activity, distinguish credential/callback/destination/identity failures from an agent-runtime failure, and resolve the specific issue or report the remaining blocker without exposing secrets. +8. Optional live qualification when explicitly requested: on either provisioned path, the wizard ends at Success and Done, with no additional test screen. Open the bot's DM directly if a conversation test was requested. The welcome DM and connection check alone do not prove an agent response. For a channel test or manual setup, send the wizard's suggested short message, for example @ you there? Find the bot belonging to this Slack app in Slack's mention suggestions and select it. Slack's actual display name can differ from Paperclip's suggested username: for example, Paperclip may suggest @cedarqa while Slack shows cedar-qa. Preserve Slack's displayed punctuation and select the real bot; pasting plain text that resembles a mention may not notify it. Use the configured bot, not a literal example. For a channel test, use a mention rather than the slash connect command. Wait for a real response in the originating Slack thread. Open the connection's Conversations page, follow the matching Open task link, and verify that the assigned agent's run completed successfully; the agent's Runs page provides the execution result if the Slack reply has no task link. Send one short follow-up in the same thread without another mention and confirm continuity and a single delivered answer. Do not claim success from a typing indicator, a verified webhook, the wizard's checkmark, or a manually posted substitute reply. If a run stops or no answer arrives, inspect the task/run and connection Activity, distinguish credential/callback/destination/identity failures from an agent-runtime failure, and resolve the specific issue or report the remaining blocker without exposing secrets. 9. Finish and review. Use Done to finish setup. The message test is optional and does not waive required credentials, callback verification, or identity linking. If I declined testing, finish without claiming it passed. Confirm the connection is saved for the correct agent/workspace, inspect its permitted destinations and linked identity, and leave existing access restrictions and per-action approvals intact. Slack tools for this agent's eligible tasks and routines use this bot connection and the responsible user's linked Slack account; do not create a separate personal Slack MCP connection or promise all methods work regardless of scopes or plan. Additional communication instructions are optional and apply to new tasks; add them only if I supplied preferences. Other people link their own accounts using the Access page's connect instructions; they do not share the bot secrets or my identity. diff --git a/ui/src/pages/apps/chat/SlackTestMessageButton.tsx b/ui/src/pages/apps/chat/SlackTestMessageButton.tsx new file mode 100644 index 00000000000..10cea6448d2 --- /dev/null +++ b/ui/src/pages/apps/chat/SlackTestMessageButton.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Loader2, Send } from "lucide-react"; +import type { SlackTestMessageResult } from "@paperclipai/shared"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { queryKeys } from "@/lib/queryKeys"; + +export function SlackTestMessageButton({ endpointId }: { endpointId: string }) { + const queryClient = useQueryClient(); + const [requestId, setRequestId] = useState(() => crypto.randomUUID()); + const [accounts, setAccounts] = useState["accounts"] | null>(null); + const [nextCursor, setNextCursor] = useState(null); + const [selected, setSelected] = useState(""); + const [invitationExpired, setInvitationExpired] = useState(false); + const [search, setSearch] = useState(""); + const send = useMutation({ + mutationFn: (input: { slackUserId?: string; accountCursor?: string; requestId?: string }) => chatEndpointsApi.sendSlackTestMessage(endpointId, { requestId, ...input }), + onSuccess: (result, input) => { + if (result.status === "connection_required") { + setAccounts(previous => input.accountCursor ? [...new Map([...(previous ?? []), ...result.accounts].map(account => [account.id, account])).values()] : result.accounts); + setNextCursor(result.nextCursor); + return; + } + setAccounts(null); + void queryClient.invalidateQueries({ queryKey: queryKeys.chatEndpoints.principals(endpointId) }); + }, + }); + const sent = send.data?.status === "sent"; + const pendingConnection = send.data?.status === "connection_pending" ? send.data : null; + useEffect(() => { + setInvitationExpired(false); + if (!pendingConnection) return; + const timer = setTimeout(() => setInvitationExpired(true), Math.max(0, new Date(pendingConnection.expiresAt).getTime() - Date.now())); + return () => clearTimeout(timer); + }, [pendingConnection?.expiresAt]); + const principals = useQuery({ + queryKey: queryKeys.chatEndpoints.principals(endpointId), + queryFn: () => chatEndpointsApi.listPrincipals(endpointId), + enabled: Boolean(pendingConnection) && !invitationExpired, refetchInterval: pendingConnection && !invitationExpired ? 3000 : false, + }); + const confirmed = Boolean(pendingConnection && principals.data?.some(link => link.principalId === pendingConnection.principalId && link.status === "linked")); + useEffect(() => { + if (confirmed && !send.isPending && !send.isError) send.mutate({ slackUserId: selected }); + }, [confirmed, send.isPending, send.isError]); + const reset = () => { setRequestId(crypto.randomUUID()); send.reset(); }; + const startTest = () => { + if (invitationExpired) { + const next = crypto.randomUUID(); setRequestId(next); + send.mutate({ requestId: next, slackUserId: selected }); + } else send.mutate({}); + }; + return
+ + {pendingConnection &&

{invitationExpired ? "The Slack invitation expired." : "Open the invitation in Slack to connect your account."}

} + {send.isError &&
+ {send.error.message} +
} + { if (!open && !send.isPending) setAccounts(null); }}> + + + Connect your Slack account + Choose your account. Messages to this agent will use your Paperclip permissions. + + setSearch(event.target.value)} /> +
+ {accounts?.filter(account => `${account.name} ${account.id}`.toLowerCase().includes(search.toLowerCase())).map(account => + )} + {accounts?.length === 0 &&

No available accounts found in this Slack workspace.

} +
+ {nextCursor && } + {send.isError &&

{send.error.message}

} +
+ + +
+
+
+
; +} diff --git a/ui/src/pages/apps/chat/SlackToolSettings.tsx b/ui/src/pages/apps/chat/SlackToolSettings.tsx index 8e2fb86df9b..772eb0af66c 100644 --- a/ui/src/pages/apps/chat/SlackToolSettings.tsx +++ b/ui/src/pages/apps/chat/SlackToolSettings.tsx @@ -1,4 +1,3 @@ -import { Link } from "react-router-dom"; import { useId, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { @@ -200,11 +199,9 @@ export function SlackSearchView({ export function SlackToolsSettings({ companyId, endpointId, - connectionId, }: { companyId: string; endpointId: string; - connectionId?: string | null; }) { const query = useQuery({ queryKey: ["slack-capabilities", companyId, endpointId], @@ -217,14 +214,7 @@ export function SlackToolsSettings({ capabilities={query.data} error={query.error?.message} /> - {connectionId && ( - - Manage action permissions - - )} +
); } diff --git a/ui/src/pages/apps/chat/chat-ui-contract.test.ts b/ui/src/pages/apps/chat/chat-ui-contract.test.ts index f67ccd5be70..34b79e374d4 100644 --- a/ui/src/pages/apps/chat/chat-ui-contract.test.ts +++ b/ui/src/pages/apps/chat/chat-ui-contract.test.ts @@ -242,7 +242,8 @@ describe("chat connector UI contract", () => { expect(setup).toContain("defaultSlackAppConfiguration"); expect(setup).not.toContain("No credentials"); expect(setup).not.toContain("managed Microsoft app"); - expect(setup).toContain("endpoint.providerAccountId && !repairing"); + expect(setup).toContain("slackSetupState({ endpoint, repairing"); + expect(source("./slack/setup-state.ts")).toContain("endpoint.providerAccountId && !repairing"); expect(setup).not.toContain('field("webhookSecret"'); expect(setup).not.toContain("@paperclipai/teams-connect"); expect(setup).not.toContain("Copy setup command"); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx new file mode 100644 index 00000000000..245773a6a70 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { StrictMode, act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, afterEach, expect, it, vi } from "vitest"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { ManagedSlackSetup } from "./ManagedSlackSetup"; + +const api = vi.hoisted(() => ({ slackSetupOptions: vi.fn(), provisionManagedSlack: vi.fn(), authorizeSlackManager: vi.fn(), get: vi.fn() })); +vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: api })); +// Stop at the navigation boundary without leaving jsdom. +const navigation = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/oauthHandoff", () => ({ prepareOAuthNavigation: navigation, savePendingCloudHandoff: vi.fn() })); +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +let endpoint: ChatEndpoint; +const props = () => ({ endpoint, stage: "app" as const, disabled: false, autoStart: true, saveDetails: vi.fn().mockResolvedValue(undefined), onSaved: vi.fn(), onBusy: vi.fn(), onSaveExit: vi.fn(), onManual: vi.fn(), onContinue: vi.fn() }); +beforeEach(() => { + vi.resetAllMocks(); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + endpoint = { id: "endpoint", companyId: "company", assignedAgentName: "Maya", setup: { slackSetupMethod: "managed" } } as ChatEndpoint; + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "grant", workspaceName: "Paperclip" }] }); + api.provisionManagedSlack.mockResolvedValue({}); api.get.mockResolvedValue(endpoint); + api.authorizeSlackManager.mockResolvedValue({ authorizationUrl: "https://slack.com/oauth" }); + navigation.mockImplementation(() => new Promise(() => {})); +}); +afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); }); +async function render(overrides = {}) { + await act(async () => { root.render(); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); +} +it("starts a single managed install after Continue even across rerenders", async () => { + await render(); await render(); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1); + expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant" })); + expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("requests explicit reinstall when reconnecting a configured app", async () => { + endpoint.setup!.slackRegistration = { status: "configured", appId: "app", managerGrantId: "grant" } as NonNullable["slackRegistration"]; + await render({ autoStart: false, reconnect: true }); + const button = Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue in Slack"); + expect(button).toBeDefined(); + await act(async () => button!.click()); + expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant", reconnect: true, requestId: expect.any(String) })); +}); +it("reuses the reconnect receipt after a lost response and failed recovery read", async () => { + endpoint.setup!.slackRegistration = { status: "configured", appId: "app", managerGrantId: "grant" } as NonNullable["slackRegistration"]; + api.provisionManagedSlack.mockRejectedValue(new Error("Response lost")); + api.get.mockRejectedValue(new Error("Recovery read unavailable")); + await render({ autoStart: false, reconnect: true }); + const button = () => Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue in Slack")!; + await act(async () => button().click()); + await act(async () => button().click()); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(2); + expect(api.provisionManagedSlack.mock.calls[1]![1]).toEqual(api.provisionManagedSlack.mock.calls[0]![1]); +}); +it("opens manager authorization directly when there is no saved workspace", async () => { + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [] }); + await render(); + expect(api.authorizeSlackManager).toHaveBeenCalledTimes(1); + expect(navigation).toHaveBeenCalledWith({ authorizationUrl: "https://slack.com/oauth" }); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); +it("does not start while Choose agent is visible or after Save & exit", async () => { + await render({ autoStart: false }); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); + expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("waits for an explicit workspace choice when there are several", async () => { + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "one", workspaceName: "One" }, { grantId: "two", workspaceName: "Two" }] }); + await render(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Choose a Slack workspace"); +}); +it.each(["uncertain", "failed", "creating", "install", "credentials_saved"])("does not automatically retry a %s registration", async status => { + endpoint.setup!.slackRegistration = { status } as NonNullable["slackRegistration"]; + await render(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("does not loop authorization after denial", async () => { + endpoint.setup!.slackManagerError = "slack_install_declined"; + await render(); expect(api.authorizeSlackManager).not.toHaveBeenCalled(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); +it("does not silently retry an ambiguous request after refresh of setup options", async () => { + api.provisionManagedSlack.mockRejectedValue(new Error("Request failed")); + await render(); await render(); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1); +}); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx new file mode 100644 index 00000000000..d9023162f02 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState, type ComponentProps } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SetupWizardFooter } from "@/components/SetupWizard"; +import type { OwnSlackAppSetup } from "./OwnSlackAppSetup"; +import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; +import { sanitizedSetupErrorMessage } from "../chat-setup-error"; + +export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, onBusy, onSaveExit, autoStart = false, reconnect = false }: ComponentProps & { autoStart?: boolean; reconnect?: boolean }) { + const choices = useQuery({ queryKey: ["slack-managed-workspaces", endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(endpoint.companyId), retry: false }); + const [selectedGrant, setSelectedGrant] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const inFlight = useRef(false); + const requestId = useRef(crypto.randomUUID()); + const registration = endpoint.setup?.slackRegistration; + const started = Boolean(registration); + const workspaces = choices.data?.workspaces ?? []; + const grantId = registration?.managerGrantId ?? selectedGrant ?? (workspaces.length === 1 ? workspaces[0]!.grantId : null); + const needsAuthorization = !grantId || registration?.errorCode === "slack_manager_reauthorize" || Boolean(grantId && !workspaces.some(workspace => workspace.grantId === grantId)); + const uncertain = registration?.status === "uncertain"; + const creating = registration?.status === "creating"; + async function run(action: () => Promise) { + if (inFlight.current) return; + inFlight.current = true; setBusy(true); onBusy(true); setError(null); + try { await action(); } + catch (failure) { setError(sanitizedSetupErrorMessage(failure, {})); await Promise.allSettled([choices.refetch(), chatEndpointsApi.get(endpoint.id).then(onSaved)]); } + finally { inFlight.current = false; setBusy(false); onBusy(false); } + } + async function authorize() { + const authorization = await chatEndpointsApi.authorizeSlackManager(endpoint.id); + const target = await prepareOAuthNavigation(authorization); + if (target.kind === "reauthentication" && authorization.handoff) savePendingCloudHandoff(authorization.handoff.session); + window.location.assign(target.url); + } + async function connect() { + if (needsAuthorization || !grantId) { + await authorize(); return; + } + if (!started) await saveDetails(); + if (confirmed || registration?.status === "failed") requestId.current = crypto.randomUUID(); + const input = { requestId: requestId.current, grantId, ...(reconnect ? { reconnect: true } : {}), ...(confirmed ? { confirmedNoAppCreated: true } : {}) }; + setConfirmed(false); + const result = await chatEndpointsApi.provisionManagedSlack(endpoint.id, input); + await choices.refetch(); + onSaved(await chatEndpointsApi.get(endpoint.id)); + if (result.authorization) window.location.assign(result.authorization.authorizationUrl); + } + const autoStarted = useRef(false); + useEffect(() => { + // Only fresh setup can start itself. Declines, uncertain creation, and saved + // registration recovery require an explicit action and never recreate an app. + if (!autoStart || autoStarted.current || disabled || !choices.data?.managedAvailable + || choices.isFetching || choices.isError || started || endpoint.setup?.slackManagerError + || workspaces.length > 1) return; + autoStarted.current = true; + void run(connect); + }); + const setupError = (code: string) => code === "slack_manifest_update_pending" + ? "Your app was created. Retry to finish its configuration." + : code === "slack_creation_uncertain" ? "Slack may have created your app. Check Slack app settings before trying again." + : slackRegistrationErrorMessage(code); + const message = error ?? (endpoint.setup?.slackManagerError ? setupError(endpoint.setup.slackManagerError) : null) ?? (choices.isError ? "Couldn't load your Slack workspaces. Try again." : registration?.errorCode ? setupError(registration.errorCode) : null); + return
+ {message &&

{message}

} + {busy || choices.isPending || creating ?

{busy ? needsAuthorization ? "Opening Slack…" : "Connecting your agent…" : creating ? "Setting up your Slack app…" : "Loading Slack…"}

+ : !choices.data?.managedAvailable ?

Managed Slack setup is unavailable. Try again later.

+ : <> + {workspaces.length === 1 &&

Add {endpoint.assignedAgentName} to {workspaces[0]!.workspaceName}.

} + {workspaces.length > 1 && } + {(!started || registration?.errorCode === "slack_manager_reauthorize") && workspaces.length > 0 &&
} + } + {uncertain &&
+ Open Slack app settings + +
} + + {choices.isError || !choices.isPending && !choices.data?.managedAvailable ? : } + +
; +} diff --git a/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx b/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx new file mode 100644 index 00000000000..a08c09376b7 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from "react"; +import { ExternalLink, Loader2 } from "lucide-react"; +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { SetupWizardFooter } from "@/components/SetupWizard"; +import { sanitizedSetupErrorMessage } from "../chat-setup-error"; +import { SlackSetupAdvanced } from "../SlackAppDetails"; + +/** Uses ordinary request-local state: configuration tokens never enter a query/mutation cache. */ +export function OwnSlackAppSetup({ endpoint, stage, disabled, saveDetails, onSaved, onBusy, + onManual, onManaged, onContinue, onSaveExit }: { + endpoint: ChatEndpoint; + stage: "app" | "credentials"; + disabled: boolean; + saveDetails: () => Promise; + onSaved: (endpoint: ChatEndpoint) => void; + onBusy: (busy: boolean) => void; + onManual: (existing: boolean) => Promise; + onManaged?: () => Promise; + onContinue: () => void; + onSaveExit: () => void; +}) { + const [configurationToken, setConfigurationToken] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [checkedNoApp, setCheckedNoApp] = useState(false); + const requestId = useRef(crypto.randomUUID()); + const inFlight = useRef(false); + const pendingWindow = useRef(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + pendingWindow.current?.close(); + pendingWindow.current = null; + }; + }, []); + const registration = endpoint.setup?.slackRegistration; + const uncertain = registration?.status === "uncertain"; + const creating = registration?.status === "creating"; + const created = Boolean(registration?.appId); + const configurationPending = registration?.errorCode === "slack_manifest_update_pending"; + + async function run(action: () => Promise) { + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); + onBusy(true); + setError(null); + try { await action(); } + catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); } + finally { + setConfigurationToken(""); + setBusy(false); + onBusy(false); + inFlight.current = false; + } + } + + async function create() { + // Reserve the window during the click so async app creation cannot trigger a popup blocker. + const installWindow = window.open("about:blank", "_blank"); + if (installWindow) { + installWindow.opener = null; + installWindow.document.title = "Preparing Slack installation"; + installWindow.document.body.textContent = "Preparing your Slack installation…"; + pendingWindow.current = installWindow; + } + let token = configurationToken.trim(); + setConfigurationToken(""); + try { + let next: ChatEndpoint; + try { + if (!created) await saveDetails(); + if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID(); + setCheckedNoApp(false); + next = await chatEndpointsApi.createSlackApp(endpoint.id, { + requestId: requestId.current, + credentials: { configurationToken: token }, + ...(checkedNoApp ? { confirmedNoAppCreated: true } : {}), + }); + } catch (failure) { + // A response can be lost after Slack creates the app. Only saved state can advance. + const current = await chatEndpointsApi.get(endpoint.id).catch(() => null); + if (current && mounted.current) onSaved(current); + if (!current?.setup?.slackRegistration?.appId) { + throw new Error(sanitizedSetupErrorMessage(failure, { configurationToken: token })); + } + next = current; + } + token = ""; + if (!mounted.current) return; + onSaved(next); + const saved = next.setup?.slackRegistration; + if (!saved?.appId) return; + onContinue(); + // Pending/uncertain configuration and existing installations must not start OAuth. + if (saved.status !== "install" || saved.errorCode) return; + if (!installWindow || installWindow.closed) { + setError("Select Install in Slack to open the installation page."); + return; + } + const result = await chatEndpointsApi.installSlackApp(endpoint.id); + if (!mounted.current) return; + if (installWindow.closed) { + setError("Select Install in Slack to reopen the installation page."); + return; + } + installWindow.location.replace(result.authorizationUrl); + pendingWindow.current = null; + } finally { + token = ""; + pendingWindow.current?.close(); + pendingWindow.current = null; + } + } + + async function authorize() { + const result = await chatEndpointsApi.installSlackApp(endpoint.id); + // Current-tab navigation avoids popup blockers. The callback resumes this saved draft. + window.location.assign(result.authorizationUrl); + } + + return
+ {error || registration?.errorCode ?

+ {error ?? slackRegistrationErrorMessage(registration!.errorCode!)} +

: null} + {(stage === "app" && !created && !creating || configurationPending) && <> + {uncertain &&
+ Open Slack app settings + +
} + {(!uncertain || checkedNoApp) &&
+ +
+
    +
  1. In Slack app settings, find Your App Configuration Tokens and choose Generate Token.
  2. +
  3. Select the workspace where you want to install your bot.
  4. +
  5. Copy the Access Token and paste it below.
  6. +
+

Paperclip uses this token to create your app

+
+ + setConfigurationToken(event.target.value)} + aria-describedby="slack-configuration-token-help" /> +
} + } + {creating &&

Creating your Slack app. You can return to this saved setup.

} + {created &&

+ {endpoint.setup?.slackApp?.appName ?? "Your Slack app"} is created. + {stage === "credentials" && " Approve installation with your Slack account. Paperclip will link it to your signed-in Paperclip account and send you a welcome DM."} +

} + {stage === "credentials" && registration?.status === "credentials_saved" &&
+

Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.

+ +
} + {stage === "app" &&
+ {onManaged && !registration && } + + +
} + + {configurationPending ? : stage === "app" ? : registration?.status === "credentials_saved" ? : } + +
; +} diff --git a/ui/src/pages/apps/chat/slack/SlackSetup.tsx b/ui/src/pages/apps/chat/slack/SlackSetup.tsx new file mode 100644 index 00000000000..e962d6d7cde --- /dev/null +++ b/ui/src/pages/apps/chat/slack/SlackSetup.tsx @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ComponentProps } from "react"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { OwnSlackAppSetup } from "./OwnSlackAppSetup"; +import { ManagedSlackSetup } from "./ManagedSlackSetup"; +export function SlackSetup(props: ComponentProps & { autoStart?: boolean; reconnect?: boolean }) { + const choices = useQuery({ queryKey: ["slack-managed-workspaces", props.endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(props.endpoint.companyId), retry: false }); + return props.endpoint.setup?.slackSetupMethod === "managed" + ? + : props.onSaved(await chatEndpointsApi.update(props.endpoint.id, { slackSetupMethod: "managed" })) : undefined} />; +} diff --git a/ui/src/pages/apps/chat/slack/setup-state.test.ts b/ui/src/pages/apps/chat/slack/setup-state.test.ts new file mode 100644 index 00000000000..b21830596b1 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/setup-state.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { slackSetupState } from "./setup-state"; +const draft = (method?: "managed" | "automatic" | "manual" | "existing") => ({ id: "endpoint", setup: { step: "provider_setup", slackSetupMethod: method } }) as ChatEndpoint; +const state = (endpoint: ChatEndpoint | null, extra = {}) => slackSetupState({ endpoint, repairing: false, credentialsReady: false, identityReady: false, ...extra }); +describe("Slack setup capabilities and progress", () => { + it("does not flash token instructions while discovery is pending", () => { + expect(state(null).labels).toEqual(["Choose agent", "Connect"]); + }); + it("defaults only new drafts and preserves legacy manual drafts", () => { + expect(state(null, { defaultMethod: "managed" }).labels).toEqual(["Choose agent", "Connect"]); + expect(state(draft(), { defaultMethod: "managed" }).provisioned).toBe(false); + expect(state(draft("automatic"), { defaultMethod: "managed" }).labels).toHaveLength(4); + }); + it("keeps managed provider operations within the two visible stops", () => { + const endpoint = draft("managed"); + endpoint.setup!.slackRegistration = { status: "install", appId: "AAPP", managementUrl: "https://api.slack.com/apps/AAPP" }; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.setup!.slackRegistration.status = "credentials_saved"; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.providerAccountId = "TWORKSPACE"; + endpoint.setup!.slackRegistration.status = "configured"; + endpoint.setup!.slackAccount = { status: "linked", externalUserId: "UPERSON", paperclipUserId: "person", welcomeStatus: "sent" }; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.setup!.step = "test"; + expect(state(endpoint).complete).toBe(true); + expect(state(endpoint).toStage(1)).toBe(3); + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + }); +}); diff --git a/ui/src/pages/apps/chat/slack/setup-state.ts b/ui/src/pages/apps/chat/slack/setup-state.ts new file mode 100644 index 00000000000..9e25bc230db --- /dev/null +++ b/ui/src/pages/apps/chat/slack/setup-state.ts @@ -0,0 +1,30 @@ +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { isProvisionedSlackSetup } from "@paperclipai/shared"; +export { isProvisionedSlackSetup }; +export function slackSetupNavigation(method: string | undefined, availableStage = 1) { + const managed = method === "managed"; + return { + labels: managed ? ["Choose agent", "Connect"] : isProvisionedSlackSetup(method) + ? ["Choose agent", "App configuration access token", "Install Slack app", "Send a message"] + : ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"], + toVisible: (step: number) => managed ? Math.min(step, 1) : step, + toStage: (step: number) => managed && step === 1 ? availableStage : step, + }; +} + +/** Logical stages stay compatible with existing draft URLs; navigation is derived here. */ +export function slackSetupState(input: { endpoint: ChatEndpoint | null; repairing: boolean; credentialsReady: boolean; identityReady: boolean; defaultMethod?: string }) { + const { endpoint, repairing, credentialsReady, identityReady } = input; + const method = endpoint ? endpoint.setup?.slackSetupMethod : input.defaultMethod ?? "managed"; + const provisioned = isProvisionedSlackSetup(method); + const complete = provisioned && (endpoint?.setup?.step === "test" || endpoint?.setup?.step === "complete"); + const finalStage = provisioned ? 3 : 5; + const availableStage = !endpoint ? 0 + : provisioned && endpoint.setup?.slackRegistration?.status === "configured" && endpoint.setup.slackAccount?.status !== "linked" && endpoint.setup.step !== "complete" ? 2 + : !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") + ? endpoint.setup.step !== "complete" && !provisioned && !identityReady ? 4 : finalStage + : provisioned && endpoint.setup?.slackRegistration?.status === "credentials_saved" ? 2 + : endpoint.providerAccountId && !repairing ? 3 + : credentialsReady || repairing || method === "existing" || provisioned && endpoint.setup?.slackRegistration?.appId ? 2 : 1; + return { ...slackSetupNavigation(method, availableStage), provisioned, complete, finalStage, availableStage }; +} diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index cd548b4f2c5..d0ad1dc0dd7 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -162,6 +162,9 @@ function installStorybookApiFixtures() { if (url.pathname === "/api/instance/settings/experimental") { return Response.json(experimentalSettings()); } + if (url.pathname === "/api/instance/settings/general") { + return Response.json(instanceGeneralSettingsSchema.parse({})); + } if (url.pathname === "/api/health") { return Response.json({ deploymentMode: "authenticated", localAiLoginSupported: onboardingFixtureState.environments === "local" }); @@ -482,9 +485,11 @@ function installStorybookApiFixtures() { type: "claude_local", label: "Claude Code", source: "builtin", - modelsCount: 2, + modelsCount: 4, loaded: true, disabled: false, + supportedRunners: ["paperclip", "legacy"], + defaultRunner: "legacy", capabilities: { supportsInstructionsBundle: true, supportsSkills: true, @@ -510,6 +515,8 @@ function installStorybookApiFixtures() { modelsCount: 3, loaded: true, disabled: false, + supportedRunners: ["paperclip", "legacy"], + defaultRunner: "legacy", capabilities: { supportsInstructionsBundle: true, supportsSkills: true, @@ -522,6 +529,16 @@ function installStorybookApiFixtures() { }, }, }, + { + type: "paperclip_runner", + label: "Paperclip Runner", + source: "builtin", + modelsCount: 0, + loaded: true, + disabled: false, + supportedRunners: ["paperclip"], + defaultRunner: "paperclip", + }, ]); } @@ -532,6 +549,7 @@ function installStorybookApiFixtures() { const [, , resource] = adapterModelsMatch; if (resource === "models") { return Response.json([ + { id: "claude-opus-5-5", label: "Claude Opus 5.5" }, { id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, @@ -850,6 +868,73 @@ const preview: Preview = { }, }, parameters: { + options: { + storySort: { + order: [ + "Overview", + "Getting started and access", + [ + "Onboarding and first work", + "Login, invitations, and access", + "Companies and portability" + ], + "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" + ], + "Agents and reusable capabilities", + [ + "Hiring, configuration, and organization", + "Runs, harnesses, and model accounts", + "Skills and Skill Studio", + "Team packages and installation" + ], + "Projects and execution", + [ + "Projects and repositories", + "Goals and work alignment", + "Workspaces, services, and files", + "Execution environments", + "Recovery" + ], + "Apps, channels, and extensions", + [ + "Connection setup", + "App access and action permissions", + "External chat and email", + "Tool gateways and access profiles", + "Secrets and proposals", + "Plugins" + ], + "Automation and structured work", + [ + "Routines, schedules, and triggers", + "Pipelines, review queues, and learnings", + "Cases", + "Status cards" + ], + "Oversight and operation", + [ + "Costs and budgets", + "Dashboards and audit trails", + "Navigation, profile, and announcements", + "Instance operations", + "CLI/API and local worktrees" + ], + "Contributor surfaces", + [ + "Developer previews and diagnostic labs" + ], + "Foundations" + ], + }, + }, actions: { argTypesRegex: "^on[A-Z].*" }, a11y: { test: "error", diff --git a/ui/storybook/fixtures/SlackSetupFixture.tsx b/ui/storybook/fixtures/SlackSetupFixture.tsx index be7b266de05..dc1d26a3991 100644 --- a/ui/storybook/fixtures/SlackSetupFixture.tsx +++ b/ui/storybook/fixtures/SlackSetupFixture.tsx @@ -7,10 +7,10 @@ import { type ChatEndpoint } from "@/api/chatEndpoints"; import { storybookAgents, storybookAuthSession } from "./paperclipData"; import { defaultSlackAppConfiguration } from "@paperclipai/shared"; -export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success"; +export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "approval_denied" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success"; /** Production wizard over a local provider fixture. No requests go to Slack. */ -export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSetupScenario }) { +export function SlackSetupFixture({ scenario = "create", managed = false, managedAvailable = true, workspaceCount = 1, optionsDelayMs = 0, optionsError = false }: { scenario?: SlackSetupScenario; managed?: boolean; managedAvailable?: boolean; workspaceCount?: number; optionsDelayMs?: number; optionsError?: boolean }) { const [ready, setReady] = useState(false); const [signalVerification, setSignalVerification] = useState<() => void>(() => () => {}); useLayoutEffect(() => { @@ -20,14 +20,16 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet const endpoint: ChatEndpoint = { id: "slack-story", companyId: "company-storybook", provider: "slack", status: "draft", assignedAgentId: agent.id, assignedAgentName: agent.name, allowUnlinkedPeople: false, - setup: { step: "provider_setup", slackSetupMethod: scenario === "manual" ? "manual" : "automatic", + setup: { step: "provider_setup", slackSetupMethod: scenario === "manual" ? "manual" : managed ? "managed" : "automatic", slackApp: defaultSlackAppConfiguration(agent.name), webhookUrl: "https://ingress.example/api/chat-webhooks/slack-story/slack", slackOAuthCallbackUri: "https://board.example/api/chat-slack/oauth/callback", }, }; + const grantId = "11111111-1111-4111-8111-111111111111"; + let authorized = workspaceCount > 0 || new URLSearchParams(window.location.search).get("slackFixtureAuthorized") === "1"; const created = () => { - endpoint.setup!.slackRegistration = { status: "install", appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" }; + endpoint.setup!.slackRegistration = { status: "install", ...(managed ? { managerGrantId: grantId } : {}), appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" }; endpoint.setup!.slackAvatar = scenario === "avatar_failed" ? { status: "failed", errorCode: "slack_avatar_upload_failed" } : { status: "uploaded", uploadedAt: new Date().toISOString() }; }; let linked = false; @@ -37,11 +39,12 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet endpoint.setup!.slackAccount = { externalUserId: "UPERSON", paperclipUserId: storybookAuthSession.user.id, status: "linked", welcomeStatus: scenario === "welcome_failed" ? "failed" : "sent", ...(scenario === "welcome_failed" ? {} : { dmChannelId: "DSTORY" }) }; endpoint.status = "verifying"; endpoint.providerAccountId = "TSTORY"; endpoint.botExternalId = "USTORY"; endpoint.botUsername = "maya"; }; - if (["install", "declined", "recovery", "manifest_pending"].includes(scenario)) created(); + if (["install", "declined", "approval_denied", "recovery", "manifest_pending"].includes(scenario)) created(); if (scenario === "manifest_pending") endpoint.setup!.slackRegistration!.errorCode = "slack_manifest_update_pending"; - if (scenario === "declined") endpoint.setup!.slackRegistration!.errorCode = "slack_install_declined"; + if (scenario === "declined") endpoint.setup!.slackRegistration!.errorCode = managed ? "slack_approval_pending" : "slack_install_declined"; + if (scenario === "approval_denied") endpoint.setup!.slackRegistration!.errorCode = "slack_approval_denied"; if (scenario === "recovery") endpoint.setup!.slackRegistration = { ...endpoint.setup!.slackRegistration!, status: "credentials_saved", errorCode: "slack_configuration_incomplete" }; - if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" }; + if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", ...(managed ? { managerGrantId: grantId } : {}), errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" }; if (["verify", "avatar_failed", "welcome_failed", "success"].includes(scenario)) installed(); if (["avatar_failed", "welcome_failed", "success"].includes(scenario)) { endpoint.setup!.webhookVerifiedAt = new Date().toISOString(); @@ -52,12 +55,26 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet window.fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); const path = url.pathname; + if (path === "/api/companies/company-storybook/chat-slack/setup-options") { + if (optionsDelayMs) await new Promise(resolve => setTimeout(resolve, optionsDelayMs)); + if (optionsError) return Response.json({ error: "Slack setup unavailable" }, { status: 503 }); + return Response.json({ managedAvailable: managed && managedAvailable, defaultMethod: managed ? "managed" : "automatic", workspaces: managed && authorized ? Array.from({ length: Math.max(1, workspaceCount) }, (_, i) => ({ grantId: i ? "22222222-2222-4222-8222-222222222222" : grantId, workspaceId: `TSTORY${i}`, workspaceName: i ? "Research workspace" : "Paperclip", userId: "UPERSON" })) : [] }); + } + if (path === "/api/chat-endpoints/slack-story/slack/managed/authorize") { + authorized = true; + const returnUrl = new URL(window.location.href); + returnUrl.hash = ""; + returnUrl.searchParams.set("slackFixtureAuthorized", "1"); + return Response.json({ authorizationUrl: returnUrl.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() }); + } + if (path === "/api/chat-endpoints/slack-story/slack/managed/provision") { installed(); return Response.json({}); } if (path === "/api/companies/company-storybook/agents") return Response.json(agents); if (path === `/api/agents/${agent.id}`) return Response.json(agent); if (path === "/api/companies/company-storybook/chat-endpoints") { const input = JSON.parse(String(init?.body ?? "{}")); agent = agents.find(value => value.id === input.assignedAgentId) ?? agent; endpoint.assignedAgentId = agent.id; endpoint.assignedAgentName = agent.name; + endpoint.setup!.slackSetupMethod = input.slackSetupMethod ?? (managed ? "managed" : "automatic"); endpoint.setup!.slackApp = input.slackApp ?? defaultSlackAppConfiguration(agent.name); return Response.json(endpoint); } @@ -94,7 +111,7 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet }; setReady(true); return () => { window.fetch = original; }; - }, [scenario]); + }, [scenario, managed, managedAvailable, workspaceCount, optionsDelayMs, optionsError]); return
; } const meta = { - title: "Design explorations/Agent chat sidebar/Components", + id: "design-explorations-agent-chat-sidebar-components", + title: "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components", component: AgentConversationSidebar, parameters: { layout: "fullscreen", docs: { description: { component: "Secondary agent navigation at the same width as the app's other contextual sidebars. Uses production AgentAvatar, Input, and Button. The entire row is a link; selection and search are interactive. Search matches agent name, title, and role. Escape clears the query. Browse all agents links to the existing agent directory in the page stories." diff --git a/ui/storybook/stories/chat-entry.stories.tsx b/ui/storybook/stories/agent-chat/chat-entry.stories.tsx similarity index 76% rename from ui/storybook/stories/chat-entry.stories.tsx rename to ui/storybook/stories/agent-chat/chat-entry.stories.tsx index a04145fb648..35137094aed 100644 --- a/ui/storybook/stories/chat-entry.stories.tsx +++ b/ui/storybook/stories/agent-chat/chat-entry.stories.tsx @@ -1,12 +1,13 @@ import { userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AgentChatPrototype } from "../prototypes/agent-chat/AgentChatPrototype"; +import { AgentChatPrototype } from "../../prototypes/agent-chat/AgentChatPrototype"; const meta = { - title: "Design explorations/Chat entry", + id: "design-explorations-chat-entry", + title: "Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry", component: AgentChatPrototype, parameters: { layout: "fullscreen", docs: { description: { component: - "Review-only proposal: Chats contains starred and recent conversations, with the first-created agent always present as a default entry. First use uses the same compact rows as returning chats. The aligned compose icon appears on hover or keyboard focus (and stays visible on touch). The picker shows search and agent results with roles and paused status. The sidebar, picker, layout, transcript, and composer are production components. Only the first-use landing page and data are Storybook fixtures. Selecting and sending use in-memory fixtures; no real agents run." + "Historical chat-entry exploration: the local sidebar and landing composition keeps a first-created-agent default. The current product uses the personal primary-agent preference and recent-conversation rules. Shared layout, picker and conversation components remain useful here, but these entry/default rules are retained design history. Selecting and sending use in-memory fixtures; no real agents run. Use Primary Agent journeys for current default-selection examples." } } }, args: { entryScenario: "returning", scenario: "returning", contextInitiallyOpen: false }, render: (args) => , diff --git a/ui/storybook/stories/agent-detail.stories.tsx b/ui/storybook/stories/agents/agent-detail.stories.tsx similarity index 97% rename from ui/storybook/stories/agent-detail.stories.tsx rename to ui/storybook/stories/agents/agent-detail.stories.tsx index 83fb19feb36..96dc123e803 100644 --- a/ui/storybook/stories/agent-detail.stories.tsx +++ b/ui/storybook/stories/agents/agent-detail.stories.tsx @@ -11,7 +11,7 @@ import type { import { AgentDetail } from "@/pages/AgentDetail"; import { useCompany } from "@/context/CompanyContext"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookAgentMap, storybookAgents, storybookIssues } from "../fixtures/paperclipData"; +import { storybookAgentMap, storybookAgents, storybookIssues } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const AGENT_ID = "agent-codex"; @@ -190,7 +190,8 @@ function AgentDetailScenario() { } const meta: Meta = { - title: "Pages/Agent Detail", + id: "pages-agent-detail", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail", parameters: { layout: "fullscreen", a11y: { test: "off" } }, }; export default meta; diff --git a/ui/storybook/stories/agent-files.stories.tsx b/ui/storybook/stories/agents/agent-files.stories.tsx similarity index 97% rename from ui/storybook/stories/agent-files.stories.tsx rename to ui/storybook/stories/agents/agent-files.stories.tsx index fdd982422fe..7a60e9119dc 100644 --- a/ui/storybook/stories/agent-files.stories.tsx +++ b/ui/storybook/stories/agents/agent-files.stories.tsx @@ -6,7 +6,7 @@ import type { AgentInstructionsFileDetail } from "@paperclipai/shared"; import { AgentFileRunNotice, PromptsTab } from "@/pages/AgentDetail"; import { Button } from "@/components/ui/button"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgents } from "../../fixtures/paperclipData"; const agent = { ...storybookAgents[0]!, id: "agent-files-story", adapterType: "codex_local" as const }; const root = "/instance/agents/agent-files-story/instructions"; @@ -106,7 +106,8 @@ function AgentFilesStory({ failedSync = false, fullStorageRun = false, historica
; } -const meta = { title: "Agents/Persistent files", component: AgentFilesStory, parameters: { layout: "fullscreen" } } satisfies Meta; +const meta = { id: "agents-persistent-files", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files", component: AgentFilesStory, parameters: { layout: "fullscreen" } } satisfies Meta; export default meta; type Story = StoryObj; export const InstructionsAndFiles: Story = {}; diff --git a/ui/storybook/stories/agent-management.stories.tsx b/ui/storybook/stories/agents/agent-management.stories.tsx similarity index 99% rename from ui/storybook/stories/agent-management.stories.tsx rename to ui/storybook/stories/agents/agent-management.stories.tsx index 7f1bee25fcc..2b877ad5cee 100644 --- a/ui/storybook/stories/agent-management.stories.tsx +++ b/ui/storybook/stories/agents/agent-management.stories.tsx @@ -38,7 +38,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { storybookAgents, storybookIssues } from "../fixtures/paperclipData"; +import { storybookAgents, storybookIssues } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const now = new Date("2026-04-20T12:00:00.000Z"); @@ -819,7 +819,8 @@ function AgentManagementStories() { } const meta = { - title: "Product/Agent Management", + id: "product-agent-management", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management", component: AgentManagementStories, parameters: { docs: { diff --git a/ui/storybook/stories/agent-persona-pages.stories.tsx b/ui/storybook/stories/agents/agent-persona-pages.stories.tsx similarity index 96% rename from ui/storybook/stories/agent-persona-pages.stories.tsx rename to ui/storybook/stories/agents/agent-persona-pages.stories.tsx index 0ae8a727590..f220ae53f89 100644 --- a/ui/storybook/stories/agent-persona-pages.stories.tsx +++ b/ui/storybook/stories/agents/agent-persona-pages.stories.tsx @@ -14,7 +14,7 @@ import { NewAgent } from "@/pages/NewAgent"; import { AgentBasicsDialog } from "@/components/new-agent/AgentBasicsDialog"; import { queryKeys } from "@/lib/queryKeys"; import { resolveAgentAppearance } from "@paperclipai/shared"; -import { storybookAgents, storybookIssues, storybookActivityEvents, storybookLiveRuns, storybookDashboardSummary } from "../fixtures/paperclipData"; +import { storybookAgents, storybookIssues, storybookActivityEvents, storybookLiveRuns, storybookDashboardSummary } from "../../fixtures/paperclipData"; const companyId = "company-storybook"; const agents = storybookAgents.map(agent => { @@ -109,7 +109,8 @@ function PersonaPage({ path, meet = false }: { path: string; meet?: boolean }) { {meet && setDialogOpen(false)} onContinue={basics => { setDialogOpen(false); navigate(`/PAP/agents/new?name=${encodeURIComponent(basics.name)}&adapterType=${basics.adapterType}`); }} />} ; } -const meta = { title: "Agents/Personas/Full pages", parameters: { layout: "fullscreen", a11y: { test: "off" } } } satisfies Meta; +const meta = { id: "agents-personas-full-pages", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages", parameters: { layout: "fullscreen", a11y: { test: "off" } } } satisfies Meta; export default meta; type Story = StoryObj; export const AllAgents: Story = { render: () => }; diff --git a/ui/storybook/stories/agent-personas.stories.tsx b/ui/storybook/stories/agents/agent-personas.stories.tsx similarity index 95% rename from ui/storybook/stories/agent-personas.stories.tsx rename to ui/storybook/stories/agents/agent-personas.stories.tsx index d4a70696294..57aeec4a853 100644 --- a/ui/storybook/stories/agent-personas.stories.tsx +++ b/ui/storybook/stories/agents/agent-personas.stories.tsx @@ -3,15 +3,16 @@ import { expect, waitFor } from "storybook/test"; import { useEffect, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette, type CharacterState } from "@paperclipai/shared"; -import { AgentAvatar, avatarSizeClasses } from "../../src/components/AgentAvatar"; -import { AgentCharacter } from "../../src/components/AgentCharacter"; -import { AgentIdentity } from "../../src/components/AgentIdentity"; -import { Button } from "../../src/components/ui/button"; +import { AgentAvatar, avatarSizeClasses } from "../../../src/components/AgentAvatar"; +import { AgentCharacter } from "../../../src/components/AgentCharacter"; +import { AgentIdentity } from "../../../src/components/AgentIdentity"; +import { Button } from "../../../src/components/ui/button"; const appearance = appearanceForPalette("bubblegum-sky"); const agent = { id: "storybook-agent", name: "Chief of Staff", appearance }; const meta = { - title: "Agents/Personas", + id: "agents-personas", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas", component: AgentCharacter, args: { appearance, size: 256, state: "listening", label: "Chief of Staff" }, parameters: { docs: { description: { component: "Persistent cap-v1 identities. The hero alone loads ClipLab. Development Storybook renders capsule PNGs locally on demand; published Storybook packages the same PNGs during its build, including every preset and both densities. Neither needs a running API." } } }, diff --git a/ui/storybook/stories/agent-settings-redesign.stories.tsx b/ui/storybook/stories/agents/agent-settings-redesign.stories.tsx similarity index 93% rename from ui/storybook/stories/agent-settings-redesign.stories.tsx rename to ui/storybook/stories/agents/agent-settings-redesign.stories.tsx index 5f5617b0df1..941825b9021 100644 --- a/ui/storybook/stories/agent-settings-redesign.stories.tsx +++ b/ui/storybook/stories/agents/agent-settings-redesign.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { AgentSettingsPreview } from "../prototypes/agent-settings/AgentSettingsPreview"; +import { AgentSettingsPreview } from "../../prototypes/agent-settings/AgentSettingsPreview"; const meta = { - title: "Agents/Configuration refresh", + id: "agents-configuration-refresh", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh", component: AgentSettingsPreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/built-in-agents.stories.tsx b/ui/storybook/stories/agents/built-in-agents.stories.tsx similarity index 98% rename from ui/storybook/stories/built-in-agents.stories.tsx rename to ui/storybook/stories/agents/built-in-agents.stories.tsx index b0bf66ddf84..9364b3fd3ab 100644 --- a/ui/storybook/stories/built-in-agents.stories.tsx +++ b/ui/storybook/stories/agents/built-in-agents.stories.tsx @@ -111,7 +111,8 @@ function SectionLabel({ children }: { children: React.ReactNode }) { } const meta: Meta = { - title: "Product/Built-in Agents", + id: "product-built-in-agents", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Product/Built-in Agents", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/codex-runner/creation.stories.tsx b/ui/storybook/stories/agents/codex-runner-creation.stories.tsx similarity index 94% rename from ui/storybook/stories/codex-runner/creation.stories.tsx rename to ui/storybook/stories/agents/codex-runner-creation.stories.tsx index 3136b151988..29cf26fe975 100644 --- a/ui/storybook/stories/codex-runner/creation.stories.tsx +++ b/ui/storybook/stories/agents/codex-runner-creation.stories.tsx @@ -6,7 +6,6 @@ import { models } from "@paperclipai/adapter-codex-local"; import { storybookHiredAgent } from "../../fixtures/paperclipData"; import { resetOnboardingFixtureState, setOnboardingFixtureState } from "../../fixtures/onboardingEnvironment"; -/** Real creation screen; existing Storybook API fixtures provide isolated accounts. */ function installCodexCreationFixture({ setupFailure = false, nativeRunnerEnabled = false, unqualifiedTarget = false }: { setupFailure?: boolean; nativeRunnerEnabled?: boolean; unqualifiedTarget?: boolean } = {}) { resetOnboardingFixtureState(); setOnboardingFixtureState({ environments: "local", authSignal: "present", localLoginStatus: "ready", savedManagedSubscription: "openai" }); @@ -27,7 +26,7 @@ function installCodexCreationFixture({ setupFailure = false, nativeRunnerEnabled if (url.pathname === "/api/adapters") { const response = await previous(input, init); const adapters = (await response.json()).map((adapter: { type: string }) => adapter.type === "codex_local" ? { ...adapter, supportedRunners: unqualifiedTarget ? ["legacy"] : ["paperclip", "legacy"], defaultRunner: unqualifiedTarget ? "legacy" : "paperclip" } : adapter); - return Response.json(nativeRunnerEnabled || unqualifiedTarget ? [...adapters, { type: "paperclip_runner", label: "Paperclip Runner", source: "builtin", loaded: true, disabled: false, modelsCount: 0 }] : adapters); + return Response.json(adapters.some((adapter: { type: string }) => adapter.type === "paperclip_runner") || !(nativeRunnerEnabled || unqualifiedTarget) ? adapters : [...adapters, { type: "paperclip_runner", label: "Paperclip Runner", source: "builtin", loaded: true, disabled: false, modelsCount: 0 }]); } if (url.pathname.endsWith("/adapters/codex_local/models")) return Response.json(models); if (url.pathname.endsWith("/adapters/codex_local/test-environment")) { @@ -52,7 +51,8 @@ function installCodexCreationFixture({ setupFailure = false, nativeRunnerEnabled } const meta = { - title: "Agents/Codex runner/Creation", + id: "agents-codex-runner-creation", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Codex runner/Creation", component: NewAgent, parameters: { layout: "fullscreen", initialEntries: ["/PAP/agents/new?name=Nova&adapterType=codex_local"], docs: { description: { component: "Production NewAgent page and shared pickers. Accounts and API replies use isolated Storybook fixtures; these stories establish UI behavior, not live execution qualification. The native default works with the old experimental gate off." } } }, beforeEach: ({ parameters }) => installCodexCreationFixture(parameters.codexCreationFixture), diff --git a/ui/storybook/stories/external-agent-invite/components.stories.tsx b/ui/storybook/stories/agents/external-agent-invite/components.stories.tsx similarity index 88% rename from ui/storybook/stories/external-agent-invite/components.stories.tsx rename to ui/storybook/stories/agents/external-agent-invite/components.stories.tsx index be2808170f3..6e08aaa085e 100644 --- a/ui/storybook/stories/external-agent-invite/components.stories.tsx +++ b/ui/storybook/stories/agents/external-agent-invite/components.stories.tsx @@ -3,7 +3,8 @@ import { fn } from "storybook/test"; import { DotConnectionChecks, ExternalAgentPresetPicker } from "@/components/new-agent/ExternalAgentInviteContent"; const meta = { - title: "Onboarding/External agent invitation/Components", + id: "onboarding-external-agent-invitation-components", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components", component: DotConnectionChecks, parameters: { layout: "centered", docs: { description: { component: "Controlled presentation states. Connection checks must be driven by server evidence. Subscription verification alone must never mark the connection ready." } } }, decorators: [Story =>
], diff --git a/ui/storybook/stories/external-agent-invite/journeys.stories.tsx b/ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx similarity index 96% rename from ui/storybook/stories/external-agent-invite/journeys.stories.tsx rename to ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx index 0c002c46722..31d87021728 100644 --- a/ui/storybook/stories/external-agent-invite/journeys.stories.tsx +++ b/ui/storybook/stories/agents/external-agent-invite/journeys.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; -import { ExternalAgentInvitePreview } from "./ExternalAgentInvitePreview"; +import { ExternalAgentInvitePreview } from "../../../support/external-agent-invite/ExternalAgentInvitePreview"; const meta = { - title: "Onboarding/External agent invitation/Journeys", + id: "onboarding-external-agent-invitation-journeys", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys", component: ExternalAgentInvitePreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/new-agent-onboarding.stories.tsx b/ui/storybook/stories/agents/new-agent-onboarding.stories.tsx similarity index 97% rename from ui/storybook/stories/new-agent-onboarding.stories.tsx rename to ui/storybook/stories/agents/new-agent-onboarding.stories.tsx index c867442654e..0c7c0a963d4 100644 --- a/ui/storybook/stories/new-agent-onboarding.stories.tsx +++ b/ui/storybook/stories/agents/new-agent-onboarding.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { userEvent, within } from "storybook/test"; -import { NewAgentWizard } from "../prototypes/NewAgentWizard"; +import { NewAgentWizard } from "../../prototypes/NewAgentWizard"; const meta = { - title: "Onboarding/New agent", + id: "onboarding-new-agent", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent", component: NewAgentWizard, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/primary-agent/00-review-guide.stories.tsx b/ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx similarity index 95% rename from ui/storybook/stories/primary-agent/00-review-guide.stories.tsx rename to ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx index 229bef555d5..851d249fe76 100644 --- a/ui/storybook/stories/primary-agent/00-review-guide.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/00-review-guide.stories.tsx @@ -50,5 +50,6 @@ function ReviewGuide() {
; } -export default { title: "Primary Agent/00 Review guide", parameters: { layout: "fullscreen" }, render: () => } satisfies Meta; +export default { id: "primary-agent-00-review-guide", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide", parameters: { layout: "fullscreen" }, render: () => } satisfies Meta; export const StartHere: StoryObj = {}; diff --git a/ui/storybook/stories/primary-agent/01-components.stories.tsx b/ui/storybook/stories/agents/primary-agent/01-components.stories.tsx similarity index 73% rename from ui/storybook/stories/primary-agent/01-components.stories.tsx rename to ui/storybook/stories/agents/primary-agent/01-components.stories.tsx index 339a71269fe..20aad95c2f9 100644 --- a/ui/storybook/stories/primary-agent/01-components.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/01-components.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { AgentIdentity } from "@/components/AgentIdentity"; import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation"; -import { primaryDecorator, primaryParameters, usePrimaryStory, type PrimaryScenario } from "./PrimaryAgentStory"; +import { primaryDecorator, primaryParameters, usePrimaryStory, type PrimaryScenario } from "../../../support/primary-agent/PrimaryAgentStory"; function Components(_args: PrimaryScenario) { const { agents } = usePrimaryStory(); @@ -11,7 +11,8 @@ function Components(_args: PrimaryScenario) { )}
; } -export default { title: "Primary Agent/01 Components", component: Components, decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, render: args => } satisfies Meta; +export default { id: "primary-agent-01-components", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components", component: Components, decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, render: args => } satisfies Meta; type Story = StoryObj; export const CrownIndicator: Story = {}; export const NoPrimary: Story = { args: { primaryId: null } }; diff --git a/ui/storybook/stories/primary-agent/02-sidebar.stories.tsx b/ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx similarity index 76% rename from ui/storybook/stories/primary-agent/02-sidebar.stories.tsx rename to ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx index e392af89f73..ca5b0d1056c 100644 --- a/ui/storybook/stories/primary-agent/02-sidebar.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/02-sidebar.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { userEvent, within } from "storybook/test"; -import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; -export default { title: "Primary Agent/02 Sidebar", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; +export default { id: "primary-agent-02-sidebar", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; type Story = StoryObj; export const IdlePrimary: Story = {}; export const ActiveColleague: Story = { parameters: { primaryAgent: { active: true } } }; diff --git a/ui/storybook/stories/primary-agent/03-agents.stories.tsx b/ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx similarity index 78% rename from ui/storybook/stories/primary-agent/03-agents.stories.tsx rename to ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx index ae4d8115a3f..17ae07fb2bc 100644 --- a/ui/storybook/stories/primary-agent/03-agents.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/03-agents.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; -export default { title: "Primary Agent/03 Agents", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; +export default { id: "primary-agent-03-agents", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => } satisfies Meta; type Story = StoryObj; export const Roster: Story = {}; export const Profile: Story = { parameters: { initialEntries: ["/PAP/agents/maia"] } }; diff --git a/ui/storybook/stories/primary-agent/04-new-task.stories.tsx b/ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx similarity index 74% rename from ui/storybook/stories/primary-agent/04-new-task.stories.tsx rename to ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx index 62a316e629e..e3d78f4d0a4 100644 --- a/ui/storybook/stories/primary-agent/04-new-task.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/04-new-task.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; -export default { title: "Primary Agent/04 New task", decorators: [primaryDecorator], parameters: primaryParameters, render: () => } satisfies Meta; +export default { id: "primary-agent-04-new-task", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task", decorators: [primaryDecorator], parameters: primaryParameters, render: () => } satisfies Meta; type Story = StoryObj; export const PrimaryFallback: Story = {}; export const RecentSpecialistWins: Story = { parameters: { primaryAgent: { recentAssignee: true } }, render: () => }; diff --git a/ui/storybook/stories/primary-agent/05-chat.stories.tsx b/ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx similarity index 68% rename from ui/storybook/stories/primary-agent/05-chat.stories.tsx rename to ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx index b5a2f3fa2aa..e487734f4dd 100644 --- a/ui/storybook/stories/primary-agent/05-chat.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/05-chat.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; -export default { title: "Primary Agent/05 Chat", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/chats"] }, render: () => } satisfies Meta; +export default { id: "primary-agent-05-chat", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/chats"] }, render: () => } satisfies Meta; type Story = StoryObj; export const PrimaryFallback: Story = {}; export const RecentConversationWins: Story = { parameters: { primaryAgent: { recentChat: true } } }; diff --git a/ui/storybook/stories/primary-agent/06-journeys.stories.tsx b/ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx similarity index 95% rename from ui/storybook/stories/primary-agent/06-journeys.stories.tsx rename to ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx index 0172759b91f..c4496e5118e 100644 --- a/ui/storybook/stories/primary-agent/06-journeys.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/06-journeys.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; -import { PrimaryPage, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; async function openActions(canvasElement: HTMLElement, name: string, profile = false) { const page = within(canvasElement.ownerDocument.body); @@ -11,7 +11,8 @@ async function openActions(canvasElement: HTMLElement, name: string, profile = f return page; } const maiaProfile = { initialEntries: ["/PAP/agents/maia"] }; -export default { title: "Primary Agent/06 Journeys", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, render: () => } satisfies Meta; +export default { id: "primary-agent-06-journeys", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, render: () => } satisfies Meta; type Story = StoryObj; async function confirmPrimaryChange(canvasElement: HTMLElement) { const page = within(canvasElement.ownerDocument.body); diff --git a/ui/storybook/stories/primary-agent/07-confirmation.stories.tsx b/ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx similarity index 82% rename from ui/storybook/stories/primary-agent/07-confirmation.stories.tsx rename to ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx index 73329989d76..be3e59897d7 100644 --- a/ui/storybook/stories/primary-agent/07-confirmation.stories.tsx +++ b/ui/storybook/stories/agents/primary-agent/07-confirmation.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory"; +import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory"; export default { - title: "Primary Agent/07 Confirmation", + id: "primary-agent-07-confirmation", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, render: () => , diff --git a/ui/storybook/stories/summary-slot-card.stories.tsx b/ui/storybook/stories/agents/summary-slot-card.stories.tsx similarity index 98% rename from ui/storybook/stories/summary-slot-card.stories.tsx rename to ui/storybook/stories/agents/summary-slot-card.stories.tsx index 9a91ef710f2..2742160f79b 100644 --- a/ui/storybook/stories/summary-slot-card.stories.tsx +++ b/ui/storybook/stories/agents/summary-slot-card.stories.tsx @@ -325,7 +325,8 @@ const DESKTOP = 640; const MOBILE = 375; const meta: Meta = { - title: "Summaries/SummarySlotCard", + id: "summaries-summaryslotcard", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard", component: CardHarness, parameters: { layout: "padded" }, }; diff --git a/ui/storybook/stories/action-card.stories.tsx b/ui/storybook/stories/app-permissions/action-card.stories.tsx similarity index 95% rename from ui/storybook/stories/action-card.stories.tsx rename to ui/storybook/stories/app-permissions/action-card.stories.tsx index 869b070ac91..5222edf27ef 100644 --- a/ui/storybook/stories/action-card.stories.tsx +++ b/ui/storybook/stories/app-permissions/action-card.stories.tsx @@ -21,7 +21,8 @@ const baseProps = { }; const meta: Meta = { - title: "Tools & Access/ActionCard", + id: "tools-access-actioncard", + title: "Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard", component: ActionCard, }; export default meta; diff --git a/ui/storybook/stories/action-permissions-test.stories.tsx b/ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx similarity index 98% rename from ui/storybook/stories/action-permissions-test.stories.tsx rename to ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx index 38f2601ca3d..235a8fde921 100644 --- a/ui/storybook/stories/action-permissions-test.stories.tsx +++ b/ui/storybook/stories/app-permissions/action-permissions-test.stories.tsx @@ -177,7 +177,8 @@ function PermissionsTestStory({ result, tool = TOOL, appName = "Asana", query }: } const meta = { - title: "Apps/Action permissions test", + id: "apps-action-permissions-test", + title: "Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test", parameters: { layout: "fullscreen" }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx b/ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx new file mode 100644 index 00000000000..7fffdf2e6ef --- /dev/null +++ b/ui/storybook/stories/app-permissions/apps-managed-accounts.stories.tsx @@ -0,0 +1,7 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ManagedAggregatorAccounts } from "../../prototypes/ManagedAggregatorAccounts"; +const meta = { id: "apps-managed-accounts", + title: "Apps, channels, and extensions/App access and action permissions/Apps/Managed accounts", component: ManagedAggregatorAccounts, parameters: { layout: "fullscreen", docs: { description: { component: "Production Apps page using production API routes and a disposable PostgreSQL database. Upstream Arcade, Composio and Executor responses are fixtures. Start tests/aggregator-accounts/test-drive.ts, then use the upstream controls and each gateway’s Refresh action." } } } } satisfies Meta; +export default meta; +type Story = StoryObj; +export const FullStackTestDrive: Story = {}; diff --git a/ui/storybook/stories/permitted-vs-installed.stories.tsx b/ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx similarity index 98% rename from ui/storybook/stories/permitted-vs-installed.stories.tsx rename to ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx index 6d5874bc630..07bb33c8104 100644 --- a/ui/storybook/stories/permitted-vs-installed.stories.tsx +++ b/ui/storybook/stories/app-permissions/permitted-vs-installed.stories.tsx @@ -122,7 +122,8 @@ function connection(id: string, name: string, installs: InstallState): ToolConne } const meta: Meta = { - title: "Tools/Permitted vs Installed", + id: "tools-permitted-vs-installed", + title: "Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/apps-managed-accounts.stories.tsx b/ui/storybook/stories/apps-managed-accounts.stories.tsx deleted file mode 100644 index fdff3c42e03..00000000000 --- a/ui/storybook/stories/apps-managed-accounts.stories.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ManagedAggregatorAccounts } from "../prototypes/ManagedAggregatorAccounts"; -const meta = { title: "Apps/Managed accounts", component: ManagedAggregatorAccounts, parameters: { layout: "fullscreen", docs: { description: { component: "Production Apps page using production API routes and a disposable PostgreSQL database. Upstream Arcade, Composio and Executor responses are fixtures. Start tests/aggregator-accounts/test-drive.ts, then use the upstream controls and each gateway’s Refresh action." } } } } satisfies Meta; -export default meta; -type Story = StoryObj; -export const FullStackTestDrive: Story = {}; diff --git a/ui/storybook/stories/budget-finance.stories.tsx b/ui/storybook/stories/budgets-costs/budget-finance.stories.tsx similarity index 99% rename from ui/storybook/stories/budget-finance.stories.tsx rename to ui/storybook/stories/budgets-costs/budget-finance.stories.tsx index 21cbe1df0c2..df59337d580 100644 --- a/ui/storybook/stories/budget-finance.stories.tsx +++ b/ui/storybook/stories/budgets-costs/budget-finance.stories.tsx @@ -765,7 +765,8 @@ function BudgetFinanceMatrix() { } const meta = { - title: "Product/Budget & Finance", + id: "product-budget-finance", + title: "Oversight and operation/Costs and budgets/Product/Budget & Finance", component: BudgetFinanceMatrix, parameters: { docs: { diff --git a/ui/storybook/stories/costs/subscriptions.stories.tsx b/ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx similarity index 98% rename from ui/storybook/stories/costs/subscriptions.stories.tsx rename to ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx index 3173cb6259a..6d55bb84345 100644 --- a/ui/storybook/stories/costs/subscriptions.stories.tsx +++ b/ui/storybook/stories/budgets-costs/costs/subscriptions.stories.tsx @@ -100,7 +100,8 @@ function SubscriptionCostsStory({ unknownPrice = false, stale = false, costGaps return ready ? <> : null; } -const meta = { title: "Costs/Subscriptions", component: SubscriptionCostsStory, parameters: { +const meta = { id: "costs-subscriptions", + title: "Oversight and operation/Costs and budgets/Costs/Subscriptions", component: SubscriptionCostsStory, parameters: { layout: "padded", docs: { description: { component: "Production Costs page with API spend, monthly subscription estimates, user and project costs. View details to edit monthly or annual prices. Provider calls are fixtures." } }, } } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/agentmail-onboarding.stories.tsx b/ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx similarity index 98% rename from ui/storybook/stories/agentmail-onboarding.stories.tsx rename to ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx index 30607a45286..0542a1c4bfc 100644 --- a/ui/storybook/stories/agentmail-onboarding.stories.tsx +++ b/ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx @@ -6,7 +6,7 @@ import { ChatSetupSidebar } from "@/components/chat/ChatSetupNavigation"; import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext"; import { useCompany } from "@/context/CompanyContext"; import { useLocation, useNavigate } from "@/lib/router"; -import { storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgents } from "../../fixtures/paperclipData"; const COMPANY = "company-storybook"; const CONNECTION = "agentmail-storybook-account"; @@ -40,7 +40,8 @@ function AgentMailJourney({ savedConnection = true }: { savedConnection?: boolea type Scenario = { noSavedKeys?: boolean; scopedKey?: boolean; missingBoundary?: boolean; denySetup?: boolean; initialTaken?: boolean; noCustomDomain?: boolean; checkFailed?: boolean }; const meta = { - title: "Connections/AgentMail setup", + id: "connections-agentmail-setup", + title: "Apps, channels, and extensions/External chat and email/Connections/AgentMail setup", component: AgentMailJourney, parameters: { layout: "fullscreen" }, args: { savedConnection: true }, diff --git a/ui/storybook/stories/agentmail-settings.stories.tsx b/ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx similarity index 97% rename from ui/storybook/stories/agentmail-settings.stories.tsx rename to ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx index 0d22f49ddcb..cb992253cab 100644 --- a/ui/storybook/stories/agentmail-settings.stories.tsx +++ b/ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx @@ -6,7 +6,8 @@ import type { EmailEndpointSummary } from "@paperclipai/shared"; const COMPANY = "company-storybook"; const ENDPOINT = "agentmail-settings-preview"; const meta = { - title: "Connections/AgentMail settings", + id: "connections-agentmail-settings", + title: "Apps, channels, and extensions/External chat and email/Connections/AgentMail settings", component: EmailEndpointSettings, parameters: { layout: "padded" }, args: { endpointId: ENDPOINT, companyId: COMPANY, assignedAgentName: "Ralph" }, diff --git a/ui/storybook/stories/agentmail.stories.tsx b/ui/storybook/stories/chat-channels/agentmail.stories.tsx similarity index 99% rename from ui/storybook/stories/agentmail.stories.tsx rename to ui/storybook/stories/chat-channels/agentmail.stories.tsx index 9e56b4fef70..0089529b4db 100644 --- a/ui/storybook/stories/agentmail.stories.tsx +++ b/ui/storybook/stories/chat-channels/agentmail.stories.tsx @@ -545,7 +545,8 @@ function EmailTaskExperience({ ); } const meta = { - title: "Connections/AgentMail tasks", + id: "connections-agentmail-tasks", + title: "Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks", component: EmailTaskExperience, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/chat-connection-actions.stories.tsx b/ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx similarity index 93% rename from ui/storybook/stories/chat-connection-actions.stories.tsx rename to ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx index b96e48bafd7..dc1793dd292 100644 --- a/ui/storybook/stories/chat-connection-actions.stories.tsx +++ b/ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx @@ -10,7 +10,8 @@ const endpoint: ChatEndpoint = { }; const meta = { - title: "Connections/Chat connection actions", + id: "connections-chat-connection-actions", + title: "Apps, channels, and extensions/External chat and email/Connections/Chat connection actions", component: ConnectorCard, parameters: { layout: "padded" }, args: { diff --git a/ui/storybook/stories/chat-management/chat-management.stories.tsx b/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx similarity index 71% rename from ui/storybook/stories/chat-management/chat-management.stories.tsx rename to ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx index 312d1445b5c..b6246aaba58 100644 --- a/ui/storybook/stories/chat-management/chat-management.stories.tsx +++ b/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx @@ -9,9 +9,9 @@ import { PluginLauncherProvider } from "@/plugins/launchers"; import { ChatEndpointDetail } from "@/pages/apps/chat/ChatEndpointDetail"; import { useCompany } from "@/context/CompanyContext"; import type { ChatEndpoint, ChatEndpointResource, ChatIdentityLink } from "@/api/chatEndpoints"; -import { storybookAgents } from "../../fixtures/paperclipData"; +import { storybookAgents } from "../../../fixtures/paperclipData"; -type Scenario = "populated" | "empty" | "error" | "channel-added"; +type Scenario = "populated" | "empty" | "error" | "channel-added" | "unlinked" | "invitation-expired"; function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?: string; scenario?: Scenario }) { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -26,7 +26,7 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab? assignedAgentId: agent.id, assignedAgentName: agent.name, providerAccountId: "TSTORY", providerAccountLabel: "Paperclip", botExternalId: "UARLO", botLabel: "Arlo", botUsername: "arlo-paperclip", allowUnlinkedPeople: false, allowDirectMessages: true, - setup: { step: "complete", slackApp: { appName: "Arlo", botName: "Arlo", command: "/arlo" } }, + setup: { step: "complete", slackRegistration: { status: "configured", appId: "ASTORY" }, slackApp: { appName: "Arlo", botName: "Arlo", command: "/arlo" } }, }; let resources: ChatEndpointResource[] = scenario === "empty" ? [] : [ { id: "engineering", type: "channel", providerResourceId: "CENGINEERING", label: "#engineering", availability: "available", enabled: true }, @@ -37,6 +37,8 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab? { id: "grace", principalId: "grace", externalLabel: "Grace Hopper", status: "pending" }, ]; let resourceReads = 0; + let invitationSent = false; + let invitationConfirmed = false; window.fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin); const path = url.pathname; @@ -62,7 +64,18 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab? } return json(resources); } - if (route === "principals") return json(principals); + if (route === "slack/test-message") { + if ((scenario === "unlinked" || scenario === "invitation-expired") && !invitationConfirmed) { + if (!JSON.parse(String(init?.body)).slackUserId) return json({ status: "connection_required", accounts: [{ id: "UADA", name: "Ada Lovelace" }], nextCursor: null }); + invitationSent = true; + return json({ status: "connection_pending", principalId: "ada", expiresAt: new Date(Date.now() + (scenario === "invitation-expired" ? 50 : 600_000)).toISOString() }); + } + return json({ status: "sent", slackUrl: "https://app.slack.com/client/TSTORY/DADA" }); + } + if (route === "principals") { + if (invitationSent && scenario !== "invitation-expired") invitationConfirmed = true; + return json(scenario === "invitation-expired" ? [] : principals); + } if (route.endsWith("/link-intent")) return json({ confirmationUrl: "https://example.test/chat-identity/confirm?token=storybook-only" }); if (route.endsWith("/link") && init?.method === "DELETE") { principals = principals.map(person => route.includes(person.id) ? { ...person, status: "revoked", paperclipUserId: null } : person); @@ -92,7 +105,8 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab? } const meta = { - title: "Connections/Chat management", + id: "connections-chat-management", + title: "Apps, channels, and extensions/External chat and email/Connections/Chat management", component: ChatManagementPage, parameters: { layout: "fullscreen", a11y: { test: "off" } }, args: { tab: "settings", scenario: "populated" }, @@ -110,11 +124,38 @@ export const InvitePeople: Story = { }, }; export const Conversations: Story = { name: "4 · Conversations", args: { tab: "conversations" } }; -export const SlackAvatar: Story = { - name: "5 · Slack avatar", args: { tab: "settings" }, +export const TestMessage: Story = { + name: "5 · Test message", args: { tab: "settings" }, play: async ({ canvasElement }) => { - await userEvent.click(await within(canvasElement).findByText("Agent avatar", { selector: "summary" })); - await expect(within(canvasElement).getByRole("img", { name: "Arlo’s Cliptoon avatar" })).toBeVisible(); + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Send me a test message" })); + await expect(await canvas.findByRole("button", { name: "Test message sent" })).toBeVisible(); + await expect(canvas.queryByRole("link", { name: "View message" })).not.toBeInTheDocument(); + await expect(canvas.getByRole("link", { name: "Open Slack configuration" })).toHaveAttribute("href", "https://api.slack.com/apps/ASTORY/oauth"); + await expect(canvas.queryByText("Agent avatar")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Message in a channel")).not.toBeInTheDocument(); + }, +}; +export const ConnectAndTest: Story = { + name: "Connect and send test message", args: { tab: "settings", scenario: "unlinked" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Send me a test message" })); + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("radio", { name: "Ada Lovelace (UADA)" })); + await userEvent.click(page.getByRole("button", { name: "Send connection invitation" })); + await expect(await canvas.findByRole("button", { name: "Test message sent" })).toBeVisible(); + }, +}; +export const InvitationExpired: Story = { + name: "Expired Slack invitation", args: { tab: "settings", scenario: "invitation-expired" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Send me a test message" })); + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("radio", { name: "Ada Lovelace (UADA)" })); + await userEvent.click(page.getByRole("button", { name: "Send connection invitation" })); + await expect(await canvas.findByRole("button", { name: "Request a new invitation" })).toBeEnabled(); }, }; export const ChannelAdded: Story = { diff --git a/ui/storybook/stories/github-bot-management/management.stories.tsx b/ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx similarity index 97% rename from ui/storybook/stories/github-bot-management/management.stories.tsx rename to ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx index 0d32d841935..38b2ed87ee0 100644 --- a/ui/storybook/stories/github-bot-management/management.stories.tsx +++ b/ui/storybook/stories/chat-channels/github-bot-management/management.stories.tsx @@ -32,11 +32,12 @@ import { configuration, reviews, conversations, -} from "./fixtures"; -import { FixtureApi, type FixtureState } from "./fixture-api"; +} from "../../../support/github-bot-management/fixtures"; +import { FixtureApi, type FixtureState } from "../../../support/github-bot-management/fixture-api"; const meta = { - title: "Connectors/GitHub bot management", + id: "connectors-github-bot-management", + title: "Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management", parameters: { layout: "fullscreen", initialEntries: [`/PAP/apps/chat/${endpoint.id}/settings`], diff --git a/ui/storybook/stories/github-chat.stories.tsx b/ui/storybook/stories/chat-channels/github-chat.stories.tsx similarity index 98% rename from ui/storybook/stories/github-chat.stories.tsx rename to ui/storybook/stories/chat-channels/github-chat.stories.tsx index 5e6d09b6e25..b32fb9383d8 100644 --- a/ui/storybook/stories/github-chat.stories.tsx +++ b/ui/storybook/stories/chat-channels/github-chat.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { GitHubChatPreview } from "../prototypes/github-chat/GitHubChatPreview"; +import { GitHubChatPreview } from "../../prototypes/github-chat/GitHubChatPreview"; const meta = { - title: "Historical/GitHub chat prototypes", + id: "historical-github-chat-prototypes", + title: "Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes", component: GitHubChatPreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/github-connections.stories.tsx b/ui/storybook/stories/chat-channels/github-connections.stories.tsx similarity index 96% rename from ui/storybook/stories/github-connections.stories.tsx rename to ui/storybook/stories/chat-channels/github-connections.stories.tsx index ed15b4ef80e..a176546e78d 100644 --- a/ui/storybook/stories/github-connections.stories.tsx +++ b/ui/storybook/stories/chat-channels/github-connections.stories.tsx @@ -10,7 +10,7 @@ import { queryKeys } from "@/lib/queryKeys"; import { Browse } from "@/pages/apps/Browse"; import { AppsConnect } from "@/pages/apps/AppsConnect"; import { ChatEndpointSetup } from "@/pages/apps/chat/ChatEndpointSetup"; -import { storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgents } from "../../fixtures/paperclipData"; const companyId = "company-storybook"; // Hosted Storybooks live below a branch path; reuse their bundled GitHub marks. @@ -78,7 +78,8 @@ function GitHubConnections({ saved = false, chatEnabled = true }) { } const meta = { - title: "Connections/GitHub and Code Review Bot", + id: "connections-github-and-code-review-bot", + title: "Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot", component: GitHubConnections, play: async ({ canvasElement }) => { await userEvent.type(await within(canvasElement).findByRole("searchbox", { name: "Search connectors" }), "GitHub"); diff --git a/ui/storybook/stories/imessage-photon.stories.tsx b/ui/storybook/stories/chat-channels/imessage-photon.stories.tsx similarity index 98% rename from ui/storybook/stories/imessage-photon.stories.tsx rename to ui/storybook/stories/chat-channels/imessage-photon.stories.tsx index 13a227a48d8..00c7b2c4621 100644 --- a/ui/storybook/stories/imessage-photon.stories.tsx +++ b/ui/storybook/stories/chat-channels/imessage-photon.stories.tsx @@ -11,7 +11,7 @@ import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble"; import { Button } from "@/components/ui/button"; import type { ChatEndpoint, ChatIdentityLink } from "@/api/chatEndpoints"; import type { TaskChatMessageItem } from "@/components/task-chat/task-chat-model"; -import { storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgents } from "../../fixtures/paperclipData"; type Screen = "catalog" | "agent" | "credentials" | "access" | "task" | "reconnect"; type Scenario = { screen?: Screen; dedicated?: boolean; outage?: boolean; noLine?: boolean; loading?: boolean; connecting?: boolean }; @@ -96,7 +96,8 @@ function Host(props: { screen?: Screen }) { } const meta = { - title: "Connections/iMessage Photon", component: Host, + id: "connections-imessage-photon", + title: "Apps, channels, and extensions/External chat and email/Connections/iMessage Photon", component: Host, parameters: { layout: "fullscreen" }, beforeEach: ({ parameters }) => { const scenario = (parameters.photonScenario ?? {}) as Scenario; diff --git a/ui/storybook/stories/slack-automatic-setup.stories.tsx b/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx similarity index 95% rename from ui/storybook/stories/slack-automatic-setup.stories.tsx rename to ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx index 4ed65ff6032..813937e17b7 100644 --- a/ui/storybook/stories/slack-automatic-setup.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { SlackSetupFixture } from "../fixtures/SlackSetupFixture"; +import { SlackSetupFixture } from "../../fixtures/SlackSetupFixture"; const start = "/PAP/apps/chat/connect?provider=slack&purpose=chat"; const meta = { - title: "Connections/Slack/Automatic setup", + id: "connections-slack-automatic-setup", + title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup", component: SlackSetupFixture, parameters: { layout: "fullscreen", initialEntries: [`${start}&resume=slack-story`], docs: { description: { component: "Production Slack setup pages in journey order. Automatic stories use the same four-step wizard; manual setup retains account linking. Slack creation, consent, verification, and identity discovery are simulated; use synthetic tokens only." } } }, diff --git a/ui/storybook/stories/slack-avatar-settings.stories.tsx b/ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx similarity index 67% rename from ui/storybook/stories/slack-avatar-settings.stories.tsx rename to ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx index 96ce07e8110..011d9367cf9 100644 --- a/ui/storybook/stories/slack-avatar-settings.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SlackAvatarSettingsPreview } from "../prototypes/slack-avatar/SlackAvatarSettingsPreview"; +import { SlackAvatarSettingsPreview } from "../../prototypes/slack-avatar/SlackAvatarSettingsPreview"; const meta = { - title: "Connections/Slack/Avatar in Settings", + id: "connections-slack-avatar-in-settings", + title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings", component: SlackAvatarSettingsPreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/slack-communication-settings.stories.tsx b/ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx similarity index 86% rename from ui/storybook/stories/slack-communication-settings.stories.tsx rename to ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx index fa28a1145dc..7e6d39a63dc 100644 --- a/ui/storybook/stories/slack-communication-settings.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { SlackAvatarSettingsPreview } from "../prototypes/slack-avatar/SlackAvatarSettingsPreview"; +import { SlackAvatarSettingsPreview } from "../../prototypes/slack-avatar/SlackAvatarSettingsPreview"; const meta = { - title: "Connections/Slack/Communication in Settings", + id: "connections-slack-communication-in-settings", + title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings", component: SlackAvatarSettingsPreview, parameters: { layout: "fullscreen", docs: { description: { component: "Production communication field in a Settings fixture. Saves use local story state; instructions apply to new tasks. Reply detail and progress are built-in behavior." } } }, } satisfies Meta; diff --git a/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx b/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx new file mode 100644 index 00000000000..5af30cc73ad --- /dev/null +++ b/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx @@ -0,0 +1,105 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { SlackSetupFixture } from "../../fixtures/SlackSetupFixture"; + +const start = "/PAP/apps/chat/connect?provider=slack&purpose=chat"; +const meta = { + id: "connections-slack-managed-setup", + title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup", component: SlackSetupFixture, + args: { managed: true }, + parameters: { layout: "fullscreen", initialEntries: [`${start}&resume=slack-story`] }, + render: args => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +/** Walk the production wizard from the same entry point as the connector catalog. */ +export const DefaultJourney: Story = { + name: "00 · Start here · Managed by default", + args: { scenario: "choose" }, + parameters: { initialEntries: ["/PAP/apps/chat/connect?provider=slack"] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText("App configuration access token")).not.toBeInTheDocument(); + await userEvent.click(await canvas.findByRole("button", { name: /^Chat with an agent/ })); + await userEvent.click(await canvas.findByRole("button", { name: "Choose an active agent" })); + await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" })); + await waitFor(() => expect(canvas.getByRole("button", { name: "Continue" })).toBeEnabled()); + await userEvent.click(canvas.getByRole("button", { name: "Continue" })); + await expect(await canvas.findByRole("heading", { name: "Send a message to your agent" })).toBeVisible(); + await expect(canvas.queryByLabelText("App configuration access token")).not.toBeInTheDocument(); + }, +}; +export const ChooseAgent: Story = { name: "01 · Choose agent", args: { scenario: "choose" }, parameters: { initialEntries: [start] } }; +export const ChooseAgentAdvanced: Story = { + ...ChooseAgent, name: "01 · Choose agent · Advanced", + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Choose an active agent" })); + await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" })); + await userEvent.click(await canvas.findByText("Advanced", { exact: true })); + await expect(await canvas.findByRole("button", { name: "Use your own app" })).toBeVisible(); + }, +}; +export const AuthorizeWorkspace: Story = { name: "02 · Connect · First authorization", args: { scenario: "create", workspaceCount: 0 } }; +export const AddToSlack: Story = { name: "02 · Connect · Returning workspace", args: { scenario: "create" }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("heading", { name: "Send a message to your agent" })).toBeVisible(); + await expect(canvas.queryByText("Use your own app")).not.toBeInTheDocument(); +} }; +export const MultipleWorkspaces: Story = { name: "02 · Connect · Choose workspace", args: { scenario: "create", workspaceCount: 2 } }; +export const Connect: Story = { name: "02 · Connect · Waiting for a message", args: { scenario: "verify" } }; +export const Success: Story = { name: "02 · Connect · Success", args: { scenario: "success" } }; +export const ApprovalPending: Story = { name: "Recovery · Slack approval pending", args: { scenario: "declined" } }; +export const ApprovalDeclined: Story = { name: "Recovery · Slack approval declined", args: { scenario: "approval_denied" } }; +export const EventConfigurationPending: Story = { name: "Recovery · App configuration pending", args: { scenario: "manifest_pending" } }; +export const UncertainCreation: Story = { name: "Recovery · Uncertain creation", args: { scenario: "uncertain" } }; +export const SavedCredentials: Story = { name: "Recovery · Saved credentials", args: { scenario: "recovery" } }; +export const Unavailable: Story = { name: "Recovery · Managed setup unavailable", args: { scenario: "install", managedAvailable: false } }; +export const OwnApp: Story = { name: "Advanced · Use your own app", args: { scenario: "choose" }, parameters: { initialEntries: [start] }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Choose an active agent" })); + await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" })); + await userEvent.click(await canvas.findByText("Advanced", { exact: true })); + await userEvent.click(await canvas.findByRole("button", { name: "Use your own app" })); + await expect(await canvas.findByRole("heading", { name: "App configuration access token" })).toBeVisible(); +} }; +export const SelfHosted: Story = { name: "Self-hosted · Own app directly", args: { scenario: "create", managed: false, managedAvailable: false } }; +export const Mobile: Story = { ...AddToSlack, name: "Mobile · Connect", globals: { viewport: { value: "mobile1", isRotated: false } } }; +export const MobileSuccess: Story = { ...Success, name: "Mobile · Success", globals: { viewport: { value: "mobile1", isRotated: false } } }; + +export const BrokerUnavailable: Story = { + ...DefaultJourney, + name: "Recovery · Broker unavailable · No token fallback", + args: { scenario: "choose", managedAvailable: false }, + play: async context => { + const entry = within(context.canvasElement); + await userEvent.click(await entry.findByRole("button", { name: /^Chat with an agent/ })); + await userEvent.click(await entry.findByRole("button", { name: "Choose an active agent" })); + await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" })); + await userEvent.click(await entry.findByRole("button", { name: "Continue" })); + const canvas = within(context.canvasElement); + await expect(await canvas.findByText("Managed Slack setup is unavailable. Try again later.")).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Retry" })).toBeVisible(); + }, +}; +export const LoadingOptions: Story = { + name: "Loading · Checking Slack setup", + args: { scenario: "choose", optionsDelayMs: 3000 }, + parameters: { initialEntries: [start] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("status")).toHaveTextContent("Loading Slack"); + await expect(canvas.getByRole("button", { name: "Continue" })).toBeDisabled(); + await expect(canvas.queryByText("App configuration access token")).not.toBeInTheDocument(); + }, +}; +export const OptionsError: Story = { + name: "Recovery · Setup lookup failed · No token fallback", + args: { scenario: "choose", optionsError: true }, + parameters: { initialEntries: [start] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Couldn't load Slack setup"); + await expect(canvas.queryByText("App configuration access token")).not.toBeInTheDocument(); + }, +}; diff --git a/ui/storybook/stories/slack-tools.stories.tsx b/ui/storybook/stories/chat-channels/slack-tools.stories.tsx similarity index 91% rename from ui/storybook/stories/slack-tools.stories.tsx rename to ui/storybook/stories/chat-channels/slack-tools.stories.tsx index 18d7b994119..c5eba3392a2 100644 --- a/ui/storybook/stories/slack-tools.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-tools.stories.tsx @@ -3,7 +3,7 @@ import { SLACK_TOOLS, type SlackSearchStatus } from "@paperclipai/shared"; import { SlackCapabilitiesView, SlackSearchView, -} from "../../src/pages/apps/chat/SlackToolSettings"; +} from "../../../src/pages/apps/chat/SlackToolSettings"; const base: SlackSearchStatus = { canConfigure: true, configured: false, @@ -52,7 +52,8 @@ function Preview({ ); } export default { - title: "Connections/Slack/Task tools", + id: "connections-slack-task-tools", + title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools", component: Preview, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/decision-models/01-settings.stories.tsx b/ui/storybook/stories/companies/decision-models/01-settings.stories.tsx similarity index 83% rename from ui/storybook/stories/decision-models/01-settings.stories.tsx rename to ui/storybook/stories/companies/decision-models/01-settings.stories.tsx index 51848e78396..f29a32edc68 100644 --- a/ui/storybook/stories/decision-models/01-settings.stories.tsx +++ b/ui/storybook/stories/companies/decision-models/01-settings.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; import { DecisionModelSettingsView } from "@/components/decision-models/DecisionModelSettings"; -import { choices, configured, result, unconfigured } from "./fixtures"; +import { choices, configured, result, unconfigured } from "../../../support/decision-models/fixtures"; -const meta = { title: "Decision models/01 Settings", component: DecisionModelSettingsView, +const meta = { id: "decision-models-01-settings", + title: "Getting started and access/Companies and portability/Decision models/01 Settings", component: DecisionModelSettingsView, decorators: [Story =>
], args: { settings: configured, choices, onSave: fn(), onTest: fn(), onAdd: fn() }, } satisfies Meta; diff --git a/ui/storybook/stories/decision-models/02-history.stories.tsx b/ui/storybook/stories/companies/decision-models/02-history.stories.tsx similarity index 81% rename from ui/storybook/stories/decision-models/02-history.stories.tsx rename to ui/storybook/stories/companies/decision-models/02-history.stories.tsx index 8ee9f752baa..5380d559d76 100644 --- a/ui/storybook/stories/decision-models/02-history.stories.tsx +++ b/ui/storybook/stories/companies/decision-models/02-history.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { DecisionHistoryTable } from "@/components/decision-models/DecisionHistory"; -import { entry } from "./fixtures"; +import { entry } from "../../../support/decision-models/fixtures"; -const meta = { title: "Decision models/02 History", component: DecisionHistoryTable, +const meta = { id: "decision-models-02-history", + title: "Getting started and access/Companies and portability/Decision models/02 History", component: DecisionHistoryTable, decorators: [Story =>
], args: { entries: [entry] }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/decision-models/03-journey.stories.tsx b/ui/storybook/stories/companies/decision-models/03-journey.stories.tsx similarity index 96% rename from ui/storybook/stories/decision-models/03-journey.stories.tsx rename to ui/storybook/stories/companies/decision-models/03-journey.stories.tsx index 8a409367d7f..2140d99c977 100644 --- a/ui/storybook/stories/decision-models/03-journey.stories.tsx +++ b/ui/storybook/stories/companies/decision-models/03-journey.stories.tsx @@ -7,7 +7,7 @@ import { Layout } from "@/components/Layout"; import { CompanySettings } from "@/pages/CompanySettings"; import { Costs } from "@/pages/Costs"; import { PluginLauncherProvider } from "@/plugins/launchers"; -import { choices, companyId, configured, entry, result, unconfigured } from "./fixtures"; +import { choices, companyId, configured, entry, result, unconfigured } from "../../../support/decision-models/fixtures"; import type { DecisionHistoryEntry } from "@paperclipai/shared"; /** Production pages and shell; only API responses and paid provider execution are simulated. */ @@ -68,7 +68,8 @@ function Journey({ alreadyConfigured = false }: { alreadyConfigured?: boolean }) } /> : null; } -const meta = { title: "Decision models/03 Journey", component: Journey, parameters: { +const meta = { id: "decision-models-03-journey", + title: "Getting started and access/Companies and portability/Decision models/03 Journey", component: Journey, parameters: { layout: "fullscreen", initialEntries: ["/PAP/company/settings"], docs: { story: { inline: false }, description: { component: "Select an existing shared connection, preserve the background setting, save, run the sample, and follow View usage. Paid calls are fixtures." } }, } } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/fast-responses/01-settings.stories.tsx b/ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx similarity index 88% rename from ui/storybook/stories/fast-responses/01-settings.stories.tsx rename to ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx index 73b61216653..e306ab47450 100644 --- a/ui/storybook/stories/fast-responses/01-settings.stories.tsx +++ b/ui/storybook/stories/companies/fast-responses/01-settings.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; import { FastResponseSettingsView } from "@/components/fast-responses/FastResponseSettings"; -import { choices } from "../decision-models/fixtures"; +import { choices } from "../../../support/decision-models/fixtures"; import type { FastResponseSettings, FastResponseTestResult, @@ -36,7 +36,9 @@ export const result: FastResponseTestResult = { }, }; const meta = { - title: "Fast responses/01 Settings", + id: "fast-responses-01-settings", + title: "Getting started and access/Companies and portability/Fast responses/01 Settings", + excludeStories: ["empty", "configured", "result"], component: FastResponseSettingsView, decorators: [ (Story) => ( @@ -52,6 +54,7 @@ const meta = { onSave: fn(), onTest: fn(), onAdd: fn(), + onConnectionChange: fn(), }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/fast-responses/02-history.stories.tsx b/ui/storybook/stories/companies/fast-responses/02-history.stories.tsx similarity index 91% rename from ui/storybook/stories/fast-responses/02-history.stories.tsx rename to ui/storybook/stories/companies/fast-responses/02-history.stories.tsx index 646074f0059..e3880534fb4 100644 --- a/ui/storybook/stories/fast-responses/02-history.stories.tsx +++ b/ui/storybook/stories/companies/fast-responses/02-history.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { FastResponseHistoryEntry } from "@paperclipai/shared"; import { DecisionHistoryTable } from "@/components/decision-models/DecisionHistory"; -import { entry } from "../decision-models/fixtures"; +import { entry } from "../../../support/decision-models/fixtures"; const receipt: FastResponseHistoryEntry = { ...entry, feature: "message", @@ -16,7 +16,8 @@ const receipt: FastResponseHistoryEntry = { costStatus: "reported", }; const meta = { - title: "Fast responses/02 History", + id: "fast-responses-02-history", + title: "Getting started and access/Companies and portability/Fast responses/02 History", component: DecisionHistoryTable, decorators: [ (Story) => ( diff --git a/ui/storybook/stories/AppLogoRegistry.stories.tsx b/ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx similarity index 86% rename from ui/storybook/stories/AppLogoRegistry.stories.tsx rename to ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx index 1c002a6bab4..218ef2d7f97 100644 --- a/ui/storybook/stories/AppLogoRegistry.stories.tsx +++ b/ui/storybook/stories/connection-setup/AppLogoRegistry.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { AppLogo } from "@/pages/apps/AppLogo"; -import manifest from "../../public/brands/apps/manifest.json"; +import manifest from "../../../public/brands/apps/manifest.json"; const sizes = [24, 28, 32, 36, 44, 48]; const launchOrder = ["gmail", "google-calendar", "slack", "stripe"]; @@ -31,7 +31,8 @@ function Registry() { ); } -const meta = { title: "Apps/Canonical icon registry", component: Registry, parameters: { layout: "fullscreen" } } satisfies Meta; +const meta = { id: "apps-canonical-icon-registry", + title: "Apps, channels, and extensions/Connection setup/Apps/Canonical icon registry", component: Registry, parameters: { layout: "fullscreen" } } satisfies Meta; export default meta; type Story = StoryObj; export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/stories/ArcadeConnection.stories.tsx b/ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx similarity index 94% rename from ui/storybook/stories/ArcadeConnection.stories.tsx rename to ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx index 04688e63b62..15186c52ec4 100644 --- a/ui/storybook/stories/ArcadeConnection.stories.tsx +++ b/ui/storybook/stories/connection-setup/ArcadeConnection.stories.tsx @@ -1,9 +1,10 @@ import { userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview"; +import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview"; const meta = { - title: "Apps/Connections/Arcade", + id: "apps-connections-arcade", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade", component: RemoteMcpConnectionReview, args: { provider: "arcade", scenario: "journey" }, parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } }, diff --git a/ui/storybook/stories/ComposioConnection.stories.tsx b/ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx similarity index 94% rename from ui/storybook/stories/ComposioConnection.stories.tsx rename to ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx index fc5940e9e5c..1919775505b 100644 --- a/ui/storybook/stories/ComposioConnection.stories.tsx +++ b/ui/storybook/stories/connection-setup/ComposioConnection.stories.tsx @@ -1,9 +1,10 @@ import { userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview"; +import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview"; const meta = { - title: "Apps/Connections/Composio", + id: "apps-connections-composio", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Composio", component: RemoteMcpConnectionReview, args: { provider: "composio", scenario: "journey" }, parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } }, diff --git a/ui/storybook/stories/ConnectionProviderChoice.stories.tsx b/ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx similarity index 96% rename from ui/storybook/stories/ConnectionProviderChoice.stories.tsx rename to ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx index 911cc7d26fc..4257a9628b1 100644 --- a/ui/storybook/stories/ConnectionProviderChoice.stories.tsx +++ b/ui/storybook/stories/connection-setup/ConnectionProviderChoice.stories.tsx @@ -9,7 +9,7 @@ import { } from "@paperclipai/shared"; import { RemoteMcpAccountChoice } from "@/features/connections/remote-mcp/RemoteMcpAccountChoice"; import { QuestionForm } from "@/components/task-chat/QuestionForm"; -import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview"; +import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview"; function ProviderChoice({ existing = false, @@ -109,7 +109,8 @@ function ProviderChoice({ ); } const meta = { - title: "Apps/Connections/Provider choice", + id: "apps-connections-provider-choice", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice", component: ProviderChoice, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/ExecutorConnection.stories.tsx b/ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx similarity index 93% rename from ui/storybook/stories/ExecutorConnection.stories.tsx rename to ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx index c4ed3881dc0..4390599ef1a 100644 --- a/ui/storybook/stories/ExecutorConnection.stories.tsx +++ b/ui/storybook/stories/connection-setup/ExecutorConnection.stories.tsx @@ -1,9 +1,10 @@ import { userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview"; +import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview"; const meta = { - title: "Apps/Connections/Executor", + id: "apps-connections-executor", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Executor", component: RemoteMcpConnectionReview, args: { provider: "executor", scenario: "journey" }, parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } }, diff --git a/ui/storybook/stories/ZapierConnection.stories.tsx b/ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx similarity index 92% rename from ui/storybook/stories/ZapierConnection.stories.tsx rename to ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx index 3199dca11d7..bb2d43cb3d0 100644 --- a/ui/storybook/stories/ZapierConnection.stories.tsx +++ b/ui/storybook/stories/connection-setup/ZapierConnection.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview"; +import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview"; const meta = { - title: "Apps/Connections/Zapier", + id: "apps-connections-zapier", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier", component: RemoteMcpConnectionReview, args: { provider: "zapier", scenario: "journey" }, parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } }, diff --git a/ui/storybook/stories/agent-setup-prompt.stories.tsx b/ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx similarity index 97% rename from ui/storybook/stories/agent-setup-prompt.stories.tsx rename to ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx index 8ee95576a40..ac6538d07c6 100644 --- a/ui/storybook/stories/agent-setup-prompt.stories.tsx +++ b/ui/storybook/stories/connection-setup/agent-setup-prompt.stories.tsx @@ -10,8 +10,8 @@ import { GitHubSetupPrompt } from "@/pages/apps/chat/GitHubSetupPrompt"; import { McpConfigHelpDialog } from "@/pages/tools/McpConfigHelpDialog"; import { AgentInstructions } from "@/components/routine-triggers/WebhookFields"; import { IssueContinuationHandoff } from "@/components/IssueContinuationHandoff"; -import { webhookAgentInstructions } from "../fixtures/routineTriggerWizard"; -import { storybookContinuationHandoff } from "../fixtures/paperclipData"; +import { webhookAgentInstructions } from "../../fixtures/routineTriggerWizard"; +import { storybookContinuationHandoff } from "../../fixtures/paperclipData"; const slackPrompt = buildSlackSetupPrompt(import.meta.env.VITE_PAPERCLIP_INSTANCE_URL ?? ""); const apiPrompt = `Help me connect my agent to the Paperclip API. @@ -23,7 +23,8 @@ Guide me through creating a dedicated agent API key. Store it securely in my age Configure the base URL and authentication, then verify the connection with a read-only request. Show me what worked and ask before making any changes to my company.`; const meta = { - title: "Components/Agent setup prompt", + id: "components-agent-setup-prompt", + title: "Apps, channels, and extensions/Connection setup/Components/Agent setup prompt", component: AgentSetupPrompt, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/apps-catalog-source-selection.stories.tsx b/ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx similarity index 95% rename from ui/storybook/stories/apps-catalog-source-selection.stories.tsx rename to ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx index a665276d96b..243d44d550d 100644 --- a/ui/storybook/stories/apps-catalog-source-selection.stories.tsx +++ b/ui/storybook/stories/connection-setup/apps-catalog-source-selection.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { ConnectorCatalogNavigation } from "../prototypes/ConnectorCatalogNavigation"; +import { ConnectorCatalogNavigation } from "../../prototypes/ConnectorCatalogNavigation"; const meta = { - title: "Apps/Catalog source selection", + id: "apps-catalog-source-selection", + title: "Apps, channels, and extensions/Connection setup/Apps/Catalog source selection", component: ConnectorCatalogNavigation, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/apps-connect-discoverability.stories.tsx b/ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx similarity index 97% rename from ui/storybook/stories/apps-connect-discoverability.stories.tsx rename to ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx index 6d8d96e1625..a0221b3cb15 100644 --- a/ui/storybook/stories/apps-connect-discoverability.stories.tsx +++ b/ui/storybook/stories/connection-setup/apps-connect-discoverability.stories.tsx @@ -58,7 +58,8 @@ function PasteConfigHost() { } const meta: Meta = { - title: "Apps/Connect discoverability (PAP-11091)", + id: "apps-connect-discoverability-pap-11091", + title: "Apps, channels, and extensions/Connection setup/Apps/Connect discoverability (PAP-11091)", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/apps-connect-name.stories.tsx b/ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx similarity index 95% rename from ui/storybook/stories/apps-connect-name.stories.tsx rename to ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx index c4511fd2a98..534ed375a32 100644 --- a/ui/storybook/stories/apps-connect-name.stories.tsx +++ b/ui/storybook/stories/connection-setup/apps-connect-name.stories.tsx @@ -82,7 +82,8 @@ function KeyStepHost({ customName }: { customName?: string }) { } const meta: Meta = { - title: "Apps/Connect name at create time (PAP-11283)", + id: "apps-connect-name-at-create-time-pap-11283", + title: "Apps, channels, and extensions/Connection setup/Apps/Connect name at create time (PAP-11283)", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/browser-use-settings.stories.tsx b/ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx similarity index 92% rename from ui/storybook/stories/browser-use-settings.stories.tsx rename to ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx index 6f1feba1f73..f8be4abe552 100644 --- a/ui/storybook/stories/browser-use-settings.stories.tsx +++ b/ui/storybook/stories/connection-setup/browser-use-settings.stories.tsx @@ -6,7 +6,7 @@ import type { ToolConnection, } from "@paperclipai/shared"; import { BrowserUseSettingsPanel } from "@/pages/apps/app-detail/BrowserUseSettingsPanel"; -import { BrowserStoryProviders, mockBrowserUse } from "../fixtures/browser-use"; +import { BrowserStoryProviders, mockBrowserUse } from "../../fixtures/browser-use"; const connection = { id: "browser-use-cloud-connection", companyId: "storybook-company", @@ -27,7 +27,8 @@ const grants = { grants: [grant("shared", "organization")], } as ConnectionGrantsResponse; const meta = { - title: "Connections/Browser Use Cloud/Settings", + id: "connections-browser-use-cloud-settings", + title: "Apps, channels, and extensions/Connection setup/Connections/Browser Use Cloud/Settings", component: BrowserUseSettingsPanel, parameters: { layout: "centered", diff --git a/ui/storybook/stories/connection-instructions.stories.tsx b/ui/storybook/stories/connection-setup/connection-instructions.stories.tsx similarity index 97% rename from ui/storybook/stories/connection-instructions.stories.tsx rename to ui/storybook/stories/connection-setup/connection-instructions.stories.tsx index ca63f1d6af7..77bb5d0c59c 100644 --- a/ui/storybook/stories/connection-instructions.stories.tsx +++ b/ui/storybook/stories/connection-setup/connection-instructions.stories.tsx @@ -13,7 +13,7 @@ import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow" import { AgentConnectionInstructions } from "@/features/connections/ConnectionInstructions"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { GUIDANCE_COMPANY, GUIDANCE_CONNECTION, guidanceAgents, installConnectionGuidanceFixtures } from "../fixtures/connectionInstructions"; +import { GUIDANCE_COMPANY, GUIDANCE_CONNECTION, guidanceAgents, installConnectionGuidanceFixtures } from "../../fixtures/connectionInstructions"; const HONCHO = getConnectableAppDefinition("honcho")!.agentInstructions!.text; const NOTION = "Use Notion as the source for our published product decisions. Look up the relevant decision before proposing a change, and link to the page you used. Ask before changing a published decision. If a page is unavailable, say which context is missing and continue with the information you have."; @@ -62,7 +62,8 @@ function ConnectionInstructionsPrototype({ initialView = "connection", provider } const meta = { - title: "Design explorations/Connections/Agent instructions", + id: "design-explorations-connections-agent-instructions", + title: "Apps, channels, and extensions/Connection setup/Design explorations/Connections/Agent instructions", component: ConnectionInstructionsPrototype, args: { providedInstructions: HONCHO }, parameters: { layout: "fullscreen", docs: { description: { component: "Production Layout, ConnectionSetupFlow, AppDetail, identity/access controls, and searchable Actions list with local API fixtures. The production instructions editor saves and reloads through mocked APIs. Baseline stories omit template metadata. No provider calls." } } }, diff --git a/ui/storybook/stories/enterpret-connect-flow.stories.tsx b/ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx similarity index 98% rename from ui/storybook/stories/enterpret-connect-flow.stories.tsx rename to ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx index d051444e9c3..b75fa183255 100644 --- a/ui/storybook/stories/enterpret-connect-flow.stories.tsx +++ b/ui/storybook/stories/connection-setup/enterpret-connect-flow.stories.tsx @@ -225,7 +225,8 @@ function MethodCopyHost() { } const meta: Meta = { - title: "Apps/Enterpret connector", + id: "apps-enterpret-connector", + title: "Apps, channels, and extensions/Connection setup/Apps/Enterpret connector", parameters: { layout: "fullscreen", docs: { diff --git a/ui/storybook/stories/fireflies-pr.stories.tsx b/ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx similarity index 98% rename from ui/storybook/stories/fireflies-pr.stories.tsx rename to ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx index 18878d8832f..c54e23d0f62 100644 --- a/ui/storybook/stories/fireflies-pr.stories.tsx +++ b/ui/storybook/stories/connection-setup/fireflies-pr.stories.tsx @@ -13,7 +13,7 @@ import { TriggersSection } from "@/components/routine-sections/editable-sections import { RoutineDetailContext, type RoutineDetailContextValue } from "@/components/routine-sections/context"; import { Button } from "@/components/ui/button"; import { useNavigate } from "@/lib/router"; -import { WebhookReview, webhook, baseRoutine } from "../fixtures/routineWebhooks"; +import { WebhookReview, webhook, baseRoutine } from "../../fixtures/routineWebhooks"; const fireflies = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "fireflies")!; const demoSecret = "storybook-only-signing-secret"; @@ -23,7 +23,8 @@ function Frame({ children }: { children: ReactNode }) { return

PR #13890 · Production components with simulated data. No provider requests or real credentials.

{children}
; } const meta = { - title: "PR reviews/Fireflies and app webhooks", + id: "pr-reviews-fireflies-and-app-webhooks", + title: "Apps, channels, and extensions/Connection setup/PR reviews/Fireflies and app webhooks", parameters: { layout: "fullscreen", docs: { description: { component: "Coverage of PR #13890: Fireflies catalog/artwork and shared connection flow, preserved action permissions, generic webhook wizard and saved settings, and both advanced trigger editors. All network mutations are simulated. Use the toolbar to switch theme or viewport. Legacy stories only demonstrate resuming existing configurations; new webhooks use Another app or script." } } }, afterEach: ({ id }) => { document.body.dataset.firefliesStoryReady = id; }, beforeEach: () => { diff --git a/ui/storybook/stories/in-feed-connections.stories.tsx b/ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx similarity index 99% rename from ui/storybook/stories/in-feed-connections.stories.tsx rename to ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx index dbcff615386..ecd1ad3f3fd 100644 --- a/ui/storybook/stories/in-feed-connections.stories.tsx +++ b/ui/storybook/stories/connection-setup/in-feed-connections.stories.tsx @@ -9,7 +9,7 @@ import { ConnectionSetupFlow, ConnectionSetupCompletionScreen, AccessStep, OAuth import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard"; -import { storybookAgentMap, storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgentMap, storybookAgents } from "../../fixtures/paperclipData"; import { useNavigate } from "@/lib/router"; import { pendingConnectionIntentInteraction as pending, @@ -37,7 +37,8 @@ const connection = { type Scenario = { email?: boolean; access?: boolean; ai?: boolean; missingAiAccount?: "anthropic" | "openai"; ownerOnly?: boolean; checking?: boolean; count?: number; loading?: boolean; loadError?: boolean; completeError?: boolean; submitting?: boolean; denied?: boolean }; const meta: Meta = { - title: "Connections/In-task connections", + id: "connections-in-task-connections", + title: "Apps, channels, and extensions/Connection setup/Connections/In-task connections", parameters: { layout: "padded" }, afterEach: ({ id }) => { document.body.dataset.inFeedStoryReady = id; }, beforeEach: ({ parameters }) => { diff --git a/ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx b/ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx similarity index 87% rename from ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx rename to ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx index 110597cc6a5..3f42764f63b 100644 --- a/ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx +++ b/ui/storybook/stories/connection-setup/managed-cloud-oauth-handoff.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { ManagedOAuthHandoffState } from "@/pages/apps/PaperclipCloudOAuthHandoff"; const meta: Meta = { - title: "Apps/Managed Cloud OAuth handoff", + id: "apps-managed-cloud-oauth-handoff", + title: "Apps, channels, and extensions/Connection setup/Apps/Managed Cloud OAuth handoff", component: ManagedOAuthHandoffState, parameters: { layout: "fullscreen" }, args: { diff --git a/ui/storybook/stories/memory-connectors.stories.tsx b/ui/storybook/stories/connection-setup/memory-connectors.stories.tsx similarity index 98% rename from ui/storybook/stories/memory-connectors.stories.tsx rename to ui/storybook/stories/connection-setup/memory-connectors.stories.tsx index c5eae9e3018..8585a9711c7 100644 --- a/ui/storybook/stories/memory-connectors.stories.tsx +++ b/ui/storybook/stories/connection-setup/memory-connectors.stories.tsx @@ -91,7 +91,8 @@ function MemoryReview({ provider = "mem0", scenario = "catalog" }: { provider?:
; } -const meta = { title: "Apps/Connections/Memory", component: MemoryReview, parameters: { layout: "fullscreen" } } satisfies Meta; +const meta = { id: "apps-connections-memory", + title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Memory", component: MemoryReview, parameters: { layout: "fullscreen" } } satisfies Meta; export default meta; type Story = StoryObj; export const CatalogAndSetup: Story = { args: { scenario: "catalog" } }; diff --git a/ui/storybook/stories/notion-connect-flow.stories.tsx b/ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx similarity index 98% rename from ui/storybook/stories/notion-connect-flow.stories.tsx rename to ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx index 0ba848c88ac..458c2a6c49c 100644 --- a/ui/storybook/stories/notion-connect-flow.stories.tsx +++ b/ui/storybook/stories/connection-setup/notion-connect-flow.stories.tsx @@ -339,7 +339,8 @@ function VercelConnectProvenanceHost() { } const meta: Meta = { - title: "Apps/Notion MCP connect flow (PAP-16650)", + id: "apps-notion-mcp-connect-flow-pap-16650", + title: "Apps, channels, and extensions/Connection setup/Apps/Notion MCP connect flow (PAP-16650)", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/personal-connection-identity.stories.tsx b/ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx similarity index 98% rename from ui/storybook/stories/personal-connection-identity.stories.tsx rename to ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx index a2f771e637f..71619ea4e0c 100644 --- a/ui/storybook/stories/personal-connection-identity.stories.tsx +++ b/ui/storybook/stories/connection-setup/personal-connection-identity.stories.tsx @@ -152,7 +152,8 @@ function IdentitiesHarness({ } const meta: Meta = { - title: "Reviews/PAP-17835 Personal connection identity", + id: "reviews-pap-17835-personal-connection-identity", + title: "Apps, channels, and extensions/Connection setup/Reviews/PAP-17835 Personal connection identity", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/ux-labs.stories.tsx b/ui/storybook/stories/developer-labs/ux-labs.stories.tsx similarity index 94% rename from ui/storybook/stories/ux-labs.stories.tsx rename to ui/storybook/stories/developer-labs/ux-labs.stories.tsx index 1a6d0e85afb..f72ae0db93c 100644 --- a/ui/storybook/stories/ux-labs.stories.tsx +++ b/ui/storybook/stories/developer-labs/ux-labs.stories.tsx @@ -13,7 +13,8 @@ function StoryFrame({ children }: { children: React.ReactNode }) { } const meta = { - title: "UX Labs/Converted Test Pages", + id: "ux-labs-converted-test-pages", + title: "Contributor surfaces/Developer previews and diagnostic labs/UX Labs/Converted Test Pages", parameters: { docs: { description: { diff --git a/ui/storybook/stories/artifact-cards/commit.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx similarity index 86% rename from ui/storybook/stories/artifact-cards/commit.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx index 1927ac77f84..91c6e0cda64 100644 --- a/ui/storybook/stories/artifact-cards/commit.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/commit.stories.tsx @@ -6,10 +6,11 @@ import { identityControls, textData, diffControls, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/Commit", + id: "explorations-artifact-cards-commit", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Commit", component: CommitCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/data.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx similarity index 85% rename from ui/storybook/stories/artifact-cards/data.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx index b203310bfe1..38842f75c12 100644 --- a/ui/storybook/stories/artifact-cards/data.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/data.stories.tsx @@ -6,10 +6,11 @@ import { identityControls, textData, objectData, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/Data", + id: "explorations-artifact-cards-data", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Data", component: DataCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/document.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx similarity index 85% rename from ui/storybook/stories/artifact-cards/document.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx index 00fac027ea0..968afbd5e5b 100644 --- a/ui/storybook/stories/artifact-cards/document.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/document.stories.tsx @@ -6,10 +6,11 @@ import { identityControls, textData, numberData, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/Document", + id: "explorations-artifact-cards-document", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Document", component: DocumentCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/file.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx similarity index 87% rename from ui/storybook/stories/artifact-cards/file.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx index bf9df2f7605..5183567678d 100644 --- a/ui/storybook/stories/artifact-cards/file.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/file.stories.tsx @@ -6,10 +6,11 @@ import { identityControls, textData, objectData, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/File", + id: "explorations-artifact-cards-file", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/File", component: FileCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/image.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx similarity index 80% rename from ui/storybook/stories/artifact-cards/image.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx index e6f7c0b258e..33efe33b3c5 100644 --- a/ui/storybook/stories/artifact-cards/image.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/image.stories.tsx @@ -6,11 +6,12 @@ import { identityControls, textData, numberData, -} from "./storyConfig"; -import posterUrl from "../../fixtures/artifact-media/paper-trail.png?url"; +} from "../../../support/artifact-cards/storyConfig"; +import posterUrl from "../../../fixtures/artifact-media/paper-trail.png?url"; const meta = { - title: "Explorations/Artifact cards/Image", + id: "explorations-artifact-cards-image", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Image", component: ImageCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/link-preview.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx similarity index 82% rename from ui/storybook/stories/artifact-cards/link-preview.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx index 7f230a559ca..baaa04c4994 100644 --- a/ui/storybook/stories/artifact-cards/link-preview.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/link-preview.stories.tsx @@ -5,10 +5,11 @@ import { parameters, identityControls, textData, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/Link preview", + id: "explorations-artifact-cards-link-preview", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Link preview", component: LinkPreviewCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/pull-request.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx similarity index 90% rename from ui/storybook/stories/artifact-cards/pull-request.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx index e7cda58f736..f992cfc6f07 100644 --- a/ui/storybook/stories/artifact-cards/pull-request.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/pull-request.stories.tsx @@ -7,10 +7,11 @@ import { textData, numberData, diffControls, -} from "./storyConfig"; +} from "../../../support/artifact-cards/storyConfig"; const meta = { - title: "Explorations/Artifact cards/Pull request", + id: "explorations-artifact-cards-pull-request", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Pull request", component: PullRequestCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-cards/video.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx similarity index 72% rename from ui/storybook/stories/artifact-cards/video.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx index 8a1f9d3cf1d..902e01eb147 100644 --- a/ui/storybook/stories/artifact-cards/video.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-cards/video.stories.tsx @@ -5,12 +5,13 @@ import { parameters, identityControls, textData, -} from "./storyConfig"; -import posterUrl from "../../fixtures/artifact-media/paper-trail.png?url"; -import videoUrl from "../../fixtures/artifact-media/paper-trail.mp4?url"; +} from "../../../support/artifact-cards/storyConfig"; +import posterUrl from "../../../fixtures/artifact-media/paper-trail.png?url"; +import videoUrl from "../../../fixtures/artifact-media/paper-trail.mp4?url"; const meta = { - title: "Explorations/Artifact cards/Video", + id: "explorations-artifact-cards-video", + title: "Tasks and conversations/Documents, attachments, and work products/Explorations/Artifact cards/Video", component: VideoCard, decorators, parameters, diff --git a/ui/storybook/stories/artifact-upload/01-components.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx similarity index 86% rename from ui/storybook/stories/artifact-upload/01-components.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx index 2e33e82d78b..7e6490d781c 100644 --- a/ui/storybook/stories/artifact-upload/01-components.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-upload/01-components.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { UploadPreview } from "./UploadPreview"; -import { showFileDrag } from "./fixtures"; +import { UploadPreview } from "../../../support/artifact-upload/UploadPreview"; +import { showFileDrag } from "../../../support/artifact-upload/fixtures"; const meta = { - title: "Artifact upload/01 Artifacts pane", component: UploadPreview, + id: "artifact-upload-01-artifacts-pane", + title: "Tasks and conversations/Documents, attachments, and work products/Artifact upload/01 Artifacts pane", component: UploadPreview, parameters: { layout: "fullscreen", docs: { description: { component: "The upload icon sits in the tab header without text or a background. The entire Artifacts panel, including its header, padding, existing cards and empty space, accepts files. Drag-over stories enter through the actual header drag handler and highlight the complete visible panel, including after scrolling. Use the upload icon for one or several files. Markdown becomes a document; other files use artifact cards. Uploads are simulated in memory and reset when the story reloads. The Uploading story holds its example pending; Retry completes the sample CSV upload. No task or execution state changes." }, story: { inline: false } } }, argTypes: { state: { control: "select", options: ["empty", "populated", "uploading", "error", "long"] } }, render: (args, context) =>
, diff --git a/ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx b/ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx similarity index 92% rename from ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx index 6068e2d1848..6dd1cfa0233 100644 --- a/ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifact-upload/02-task-journey.stories.tsx @@ -10,8 +10,8 @@ import { ArrowLeft, Box, X } from "lucide-react"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useSidebar } from "@/context/SidebarContext"; -import { UploadPreview } from "./UploadPreview"; -import { issue, showFileDrag, type PreviewState } from "./fixtures"; +import { UploadPreview } from "../../../support/artifact-upload/UploadPreview"; +import { issue, showFileDrag, type PreviewState } from "../../../support/artifact-upload/fixtures"; function TaskPage({ state = "empty", panelOpenInitially = false }: { state?: PreviewState; panelOpenInitially?: boolean }) { const { setBreadcrumbs } = useBreadcrumbs(); @@ -45,7 +45,8 @@ function Journey(props: { state?: PreviewState; panelOpenInitially?: boolean }) } const meta = { - title: "Artifact upload/02 Paused task journey", component: Journey, + id: "artifact-upload-02-paused-task-journey", + title: "Tasks and conversations/Documents, attachments, and work products/Artifact upload/02 Paused task journey", component: Journey, parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/PAP-214"], docs: { description: { component: "Start on the paused, unassigned PAP-214 placeholder in the production app shell. Upload or drop files in Artifacts, open a document in another tab, then return to Artifacts. On mobile, open Artifacts from the task header. Uses production chat bubbles, paused takeover, panel frame, desktop/mobile tabs, artifact list and document viewer. The story composes these components for review; upload persistence is simulated. No live issue is modified or resumed." }, story: { inline: false } } }, render: (args, context) => , } satisfies Meta; diff --git a/ui/storybook/stories/artifacts.stories.tsx b/ui/storybook/stories/documents-artifacts/artifacts.stories.tsx similarity index 99% rename from ui/storybook/stories/artifacts.stories.tsx rename to ui/storybook/stories/documents-artifacts/artifacts.stories.tsx index 10a8723c525..ea0cbb85d83 100644 --- a/ui/storybook/stories/artifacts.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/artifacts.stories.tsx @@ -475,7 +475,8 @@ const SELECTED_GROUP_ARTIFACTS: CompanyArtifact[] = [ ]; const meta: Meta = { - title: "Pages/Artifacts", + id: "pages-artifacts", + title: "Tasks and conversations/Documents, attachments, and work products/Pages/Artifacts", }; export default meta; diff --git a/ui/storybook/stories/csv-preview.stories.tsx b/ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx similarity index 95% rename from ui/storybook/stories/csv-preview.stories.tsx rename to ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx index 40dc7e2b302..2c275cef749 100644 --- a/ui/storybook/stories/csv-preview.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/csv-preview.stories.tsx @@ -6,7 +6,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel"; const meta: Meta = { - title: "Components/CSV File Tab", + id: "components-csv-file-tab", + title: "Tasks and conversations/Documents, attachments, and work products/Components/CSV File Tab", component: TextAttachmentPreview, parameters: { layout: "fullscreen" }, decorators: [(Story) =>
], diff --git a/ui/storybook/stories/document-annotations.stories.tsx b/ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx similarity index 99% rename from ui/storybook/stories/document-annotations.stories.tsx rename to ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx index 63a18f0c6e7..deae38c99ba 100644 --- a/ui/storybook/stories/document-annotations.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/document-annotations.stories.tsx @@ -504,7 +504,8 @@ function StatesShowcase({ focusedThreadId = "open-1" }: { focusedThreadId?: stri } const meta = { - title: "Product/Documents/Annotations", + id: "product-documents-annotations", + title: "Tasks and conversations/Documents, attachments, and work products/Product/Documents/Annotations", component: StatesShowcase, parameters: { docs: { diff --git a/ui/storybook/stories/document-comments.stories.tsx b/ui/storybook/stories/documents-artifacts/document-comments.stories.tsx similarity index 97% rename from ui/storybook/stories/document-comments.stories.tsx rename to ui/storybook/stories/documents-artifacts/document-comments.stories.tsx index 0e03550df28..befef1c6b10 100644 --- a/ui/storybook/stories/document-comments.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/document-comments.stories.tsx @@ -169,7 +169,8 @@ function DocumentCommentsMatrix() { } const meta = { - title: "Product/Document Comments", + id: "product-document-comments", + title: "Tasks and conversations/Documents, attachments, and work products/Product/Document Comments", component: DocumentCommentsMatrix, parameters: { docs: { diff --git a/ui/storybook/stories/file-viewer.stories.tsx b/ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx similarity index 98% rename from ui/storybook/stories/file-viewer.stories.tsx rename to ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx index aa3ddaa437f..cbed714bd0e 100644 --- a/ui/storybook/stories/file-viewer.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/file-viewer.stories.tsx @@ -212,7 +212,8 @@ function buildFetchStub( } const meta: Meta = { - title: "Components/File Viewer Sheet", + id: "components-file-viewer-sheet", + title: "Tasks and conversations/Documents, attachments, and work products/Components/File Viewer Sheet", component: FileViewerSheet, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/html-artifacts/01-preview.stories.tsx b/ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx similarity index 87% rename from ui/storybook/stories/html-artifacts/01-preview.stories.tsx rename to ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx index 953713b37ae..9059baf18ce 100644 --- a/ui/storybook/stories/html-artifacts/01-preview.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/html-artifacts/01-preview.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel"; -import { reportHtml, securityHtml } from "./fixtures"; +import { reportHtml, securityHtml } from "../../../support/html-artifacts/fixtures"; function Preview({ html = reportHtml, title = "repository-usage.html", width = 800 }: { html?: string; title?: string; width?: number }) { return
@@ -11,7 +11,8 @@ function Preview({ html = reportHtml, title = "repository-usage.html", width = 8 } const meta = { - title: "HTML artifacts/01 Preview", + id: "html-artifacts-01-preview", + title: "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/01 Preview", component: Preview, args: { html: reportHtml, title: "repository-usage.html", width: 800 }, parameters: { layout: "fullscreen", docs: { description: { component: "Production attachment viewer. Inline CSS and JavaScript run in an opaque-origin sandbox. Remote dependencies and API requests are blocked. The original file is available through Download or Raw." }, story: { inline: false } } }, diff --git a/ui/storybook/stories/html-artifacts/02-workspace.stories.tsx b/ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx similarity index 84% rename from ui/storybook/stories/html-artifacts/02-workspace.stories.tsx rename to ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx index eedb1f17d0d..71e58e8b143 100644 --- a/ui/storybook/stories/html-artifacts/02-workspace.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/html-artifacts/02-workspace.stories.tsx @@ -6,7 +6,7 @@ import { TaskWorkspaceFilePanel } from "@/components/task-side-panel/TaskWorkspa import { FileViewerSheet } from "@/components/FileViewerSheet"; import { FileViewerProvider } from "@/context/FileViewerContext"; import { queryKeys } from "@/lib/queryKeys"; -import { reportHtml } from "./fixtures"; +import { reportHtml } from "../../../support/html-artifacts/fixtures"; const issueId = "html-workspace-story"; const state = { path: "reports/repository-usage.html", workspace: "project" as const, workspaceId: null, projectId: null, line: null, column: null }; @@ -32,7 +32,8 @@ function Workspace({ sheet = false }: { sheet?: boolean }) {
; } -const meta = { title: "HTML artifacts/02 Workspace", component: Workspace, render: (args, context) => , parameters: { layout: "fullscreen", docs: { story: { inline: false } } } } satisfies Meta; +const meta = { id: "html-artifacts-02-workspace", + title: "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/02 Workspace", component: Workspace, render: (args, context) => , parameters: { layout: "fullscreen", docs: { story: { inline: false } } } } satisfies Meta; export default meta; type Story = StoryObj; export const TaskPanel: Story = {}; diff --git a/ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx b/ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx similarity index 93% rename from ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx rename to ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx index 80458df3e00..6b9df5170fb 100644 --- a/ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx @@ -10,9 +10,9 @@ import { IssueArtifactFile } from "@/components/artifacts/IssueArtifactCard"; import { TextAttachmentContext } from "@/context/TextAttachmentContext"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { queryKeys } from "@/lib/queryKeys"; -import { createIssue, storybookAgents } from "../../fixtures/paperclipData"; -import { reportHtml } from "./fixtures"; -import reportDownload from "./report.html?url"; +import { createIssue, storybookAgents } from "../../../fixtures/paperclipData"; +import { reportHtml } from "../../../support/html-artifacts/fixtures"; +import reportDownload from "../../../support/html-artifacts/report.html?url"; const issue = createIssue({ id: "html-artifact-journey", identifier: "DEMO-101", title: "Find popular repositories that use Paperclip a lot", status: "done", completedAt: new Date("2026-10-07T12:54:00Z"), currentExecutionWorkspace: null, executionWorkspaceId: null }); const attachment: IssueAttachment = { @@ -63,7 +63,8 @@ function Journey(props: { openInitially?: boolean }) { ; } const meta = { - title: "HTML artifacts/03 Task journey", component: Journey, + id: "html-artifacts-03-task-journey", + title: "Tasks and conversations/Documents, attachments, and work products/HTML artifacts/03 Task journey", component: Journey, parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/html-artifact-journey"], docs: { description: { component: "Open the HTML artifact from the task's report card or Artifacts tab, filter the report, then switch to Raw beside Download. Uses the production app shell, artifact card, task panel and HTML renderer with simulated report data." }, story: { inline: false } } }, beforeEach: () => { const originalFetch = window.fetch; diff --git a/ui/storybook/stories/rich-work-product-cards.stories.tsx b/ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx similarity index 96% rename from ui/storybook/stories/rich-work-product-cards.stories.tsx rename to ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx index a85c39c499e..5127de63abc 100644 --- a/ui/storybook/stories/rich-work-product-cards.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/rich-work-product-cards.stories.tsx @@ -1,12 +1,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { IssueAttachment, IssueWorkProduct } from "@paperclipai/shared"; import { expect } from "storybook/test"; -import { RichWorkProductCard } from "../../src/components/task-chat/RichWorkProductCard"; -import { TaskChatBubble } from "../../src/components/task-chat/TaskChatBubble"; -import type { TaskChatMessageItem } from "../../src/components/task-chat/task-chat-model"; +import { RichWorkProductCard } from "../../../src/components/task-chat/RichWorkProductCard"; +import { TaskChatBubble } from "../../../src/components/task-chat/TaskChatBubble"; +import type { TaskChatMessageItem } from "../../../src/components/task-chat/task-chat-model"; const meta = { - title: "Task Chat/Rich Work Product Cards", + id: "task-chat-rich-work-product-cards", + title: "Tasks and conversations/Documents, attachments, and work products/Task Chat/Rich Work Product Cards", component: RichWorkProductCard, parameters: { layout: "centered" }, } satisfies Meta; diff --git a/ui/storybook/stories/task-artifact-gallery.stories.tsx b/ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx similarity index 92% rename from ui/storybook/stories/task-artifact-gallery.stories.tsx rename to ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx index 79a021486e2..f2a2cbcee71 100644 --- a/ui/storybook/stories/task-artifact-gallery.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/task-artifact-gallery.stories.tsx @@ -10,14 +10,14 @@ import { queryKeys } from "@/lib/queryKeys"; import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output"; import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble"; import { workProductHref } from "@/lib/issue-artifacts"; -import { createIssue, storybookAgents } from "../fixtures/paperclipData"; -import manila from "../fixtures/artifact-media/manila-ledger.mp4?url"; -import night from "../fixtures/artifact-media/night-pills.mp4?url"; -import tickets from "../fixtures/artifact-media/ticket-board.mp4?url"; -import type from "../fixtures/artifact-media/big-type.mp4?url"; -import trail from "../fixtures/artifact-media/paper-trail.mp4?url"; -import manilaImage from "../fixtures/artifact-media/manila-ledger.png?url"; -import nightImage from "../fixtures/artifact-media/night-pills.png?url"; +import { createIssue, storybookAgents } from "../../fixtures/paperclipData"; +import manila from "../../fixtures/artifact-media/manila-ledger.mp4?url"; +import night from "../../fixtures/artifact-media/night-pills.mp4?url"; +import tickets from "../../fixtures/artifact-media/ticket-board.mp4?url"; +import type from "../../fixtures/artifact-media/big-type.mp4?url"; +import trail from "../../fixtures/artifact-media/paper-trail.mp4?url"; +import manilaImage from "../../fixtures/artifact-media/manila-ledger.png?url"; +import nightImage from "../../fixtures/artifact-media/night-pills.png?url"; const issue = createIssue({ id: "artifact-gallery-story", identifier: "DEMO-101", title: "Explore Paperclip Ships styles", status: "done" }); const date = new Date("2026-09-22T18:03:00Z"); @@ -93,7 +93,8 @@ function GalleryStory({ scenario = "videos", width = 480 }: { scenario?: Scenari ); } const meta = { - title: "Tasks/Artifact Gallery", component: GalleryStory, parameters: { layout: "fullscreen" }, + id: "tasks-artifact-gallery", + title: "Tasks and conversations/Documents, attachments, and work products/Tasks/Artifact Gallery", component: GalleryStory, parameters: { layout: "fullscreen" }, args: { scenario: "videos", width: 480 }, beforeEach: ({ args }) => { const originalFetch = window.fetch; diff --git a/ui/storybook/stories/task-text-files.stories.tsx b/ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx similarity index 91% rename from ui/storybook/stories/task-text-files.stories.tsx rename to ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx index e1db636b869..7e0c73ec666 100644 --- a/ui/storybook/stories/task-text-files.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/task-text-files.stories.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { IssueAttachment } from "@paperclipai/shared"; import { TaskSidePanel } from "@/components/task-side-panel/TaskSidePanel"; import { queryKeys } from "@/lib/queryKeys"; -import { createIssue } from "../fixtures/paperclipData"; +import { createIssue } from "../../fixtures/paperclipData"; const markdown = "# Account review charter\n\nInvestigate whether new and active accounts succeeded.\n\n## Rules\n\n- Read customer data only.\n- Keep private information out of reports.\n- Report failures with evidence.\n\n```text\nReview → Evidence → Report\n```"; const plain = "Daily review summary\n\nAccounts reviewed: 20\nCompleted successfully: 18\nNeeds investigation: 2\n\n"; @@ -40,7 +40,8 @@ function TextFilesStory({ initial = "list", width = 640 }: { initial?: "list" | ; } -const meta = { title: "Tasks/Text file tabs", component: TextFilesStory, parameters: { layout: "fullscreen" }, render: (args, context) => } satisfies Meta; +const meta = { id: "tasks-text-file-tabs", + title: "Tasks and conversations/Documents, attachments, and work products/Tasks/Text file tabs", component: TextFilesStory, parameters: { layout: "fullscreen" }, render: (args, context) => } satisfies Meta; export default meta; type Story = StoryObj; export const OpenFromArtifacts: Story = {}; diff --git a/ui/storybook/stories/forms-editors.stories.tsx b/ui/storybook/stories/foundations/forms-editors.stories.tsx similarity index 99% rename from ui/storybook/stories/forms-editors.stories.tsx rename to ui/storybook/stories/foundations/forms-editors.stories.tsx index 7f1a723acda..e418820fd13 100644 --- a/ui/storybook/stories/forms-editors.stories.tsx +++ b/ui/storybook/stories/foundations/forms-editors.stories.tsx @@ -20,7 +20,7 @@ import { ScheduleEditor, describeSchedule } from "@/components/ScheduleEditor"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { buildExecutionPolicy } from "@/lib/issue-execution-policy"; -import { createIssue, storybookAgents } from "../fixtures/paperclipData"; +import { createIssue, storybookAgents } from "../../fixtures/paperclipData"; function Section({ eyebrow, @@ -712,7 +712,8 @@ function RoutineRunDialogStory() { } const meta = { - title: "Components/Forms & Editors", + id: "components-forms-editors", + title: "Foundations/Components/Forms & Editors", parameters: { docs: { description: { diff --git a/ui/storybook/stories/foundations.stories.tsx b/ui/storybook/stories/foundations/foundations.stories.tsx similarity index 99% rename from ui/storybook/stories/foundations.stories.tsx rename to ui/storybook/stories/foundations/foundations.stories.tsx index 94c1a6874e4..86d1c6eb810 100644 --- a/ui/storybook/stories/foundations.stories.tsx +++ b/ui/storybook/stories/foundations/foundations.stories.tsx @@ -281,6 +281,7 @@ function FoundationsMatrix() { } const meta = { + id: "foundations-primitive-matrix", title: "Foundations/Primitive Matrix", component: FoundationsMatrix, parameters: { diff --git a/ui/storybook/stories/primitives-coverage.stories.tsx b/ui/storybook/stories/foundations/primitives-coverage.stories.tsx similarity index 99% rename from ui/storybook/stories/primitives-coverage.stories.tsx rename to ui/storybook/stories/foundations/primitives-coverage.stories.tsx index 164f97f9361..98d99b099ad 100644 --- a/ui/storybook/stories/primitives-coverage.stories.tsx +++ b/ui/storybook/stories/foundations/primitives-coverage.stories.tsx @@ -69,6 +69,7 @@ import { Skeleton } from "@/components/ui/skeleton"; * static, deterministic state so the visual snapshot suite baselines it. */ const meta = { + id: "foundations-primitive-coverage", title: "Foundations/Primitive Coverage", parameters: { docs: { diff --git a/ui/storybook/stories/status-language.stories.tsx b/ui/storybook/stories/foundations/status-language.stories.tsx similarity index 99% rename from ui/storybook/stories/status-language.stories.tsx rename to ui/storybook/stories/foundations/status-language.stories.tsx index b39ab4b99c5..82d356b9fb8 100644 --- a/ui/storybook/stories/status-language.stories.tsx +++ b/ui/storybook/stories/foundations/status-language.stories.tsx @@ -17,7 +17,7 @@ import { QuotaBar } from "@/components/QuotaBar"; import { StatusBadge } from "@/components/StatusBadge"; import { StatusIcon } from "@/components/StatusIcon"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { createIssue } from "../fixtures/paperclipData"; +import { createIssue } from "../../fixtures/paperclipData"; function Section({ eyebrow, @@ -589,6 +589,7 @@ function StatusLanguage() { } const meta = { + id: "foundations-status-language", title: "Foundations/Status Language", component: StatusLanguage, parameters: { diff --git a/ui/storybook/stories/access-profiles.stories.tsx b/ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx similarity index 99% rename from ui/storybook/stories/access-profiles.stories.tsx rename to ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx index 4ad365bdd65..c236a5bfae0 100644 --- a/ui/storybook/stories/access-profiles.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/access-profiles.stories.tsx @@ -474,7 +474,8 @@ function SeededAgentTools() { } const meta: Meta = { - title: "Tools/Access profiles", + id: "tools-access-profiles", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Tools/Access profiles", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/gateways-tab.stories.tsx b/ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx similarity index 98% rename from ui/storybook/stories/gateways-tab.stories.tsx rename to ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx index 1a24d272db1..1eac81d5aa1 100644 --- a/ui/storybook/stories/gateways-tab.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/gateways-tab.stories.tsx @@ -242,7 +242,8 @@ function GatewaysHost({ gateways }: { gateways: ToolMcpGatewayWithTokens[] }) { } const meta: Meta = { - title: "Tools/Gateways (PAP-11182)", + id: "tools-gateways-pap-11182", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Tools/Gateways (PAP-11182)", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/public-mcp-connections.stories.tsx b/ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx similarity index 90% rename from ui/storybook/stories/public-mcp-connections.stories.tsx rename to ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx index fd6623e7599..0f45100710d 100644 --- a/ui/storybook/stories/public-mcp-connections.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/public-mcp-connections.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "storybook/test"; import { AssistantConnectionsPage } from "@/pages/McpConnect"; -import { installPublicMcpFixture } from "../fixtures/publicMcp"; +import { installPublicMcpFixture } from "../../fixtures/publicMcp"; const meta = { - title: "Assistant connections/Manage connections", component: AssistantConnectionsPage, + id: "assistant-connections-manage-connections", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Manage connections", component: AssistantConnectionsPage, parameters: { layout: "padded", initialEntries: ["/assistant-connections"], docs: { description: { component: "Production /assistant-connections page. Revocation changes only the in-memory story fixture." } } }, beforeEach: ({ parameters }) => installPublicMcpFixture(parameters.fixture), } satisfies Meta; diff --git a/ui/storybook/stories/public-mcp-consent.stories.tsx b/ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx similarity index 98% rename from ui/storybook/stories/public-mcp-consent.stories.tsx rename to ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx index 5a305fbfac0..543046702af 100644 --- a/ui/storybook/stories/public-mcp-consent.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/public-mcp-consent.stories.tsx @@ -2,10 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; import { Route, Routes } from "@/lib/router"; import { McpConnectPage, AssistantConnectionsPage } from "@/pages/McpConnect"; -import { consentSubmission, installPublicMcpFixture, request } from "../fixtures/publicMcp"; +import { consentSubmission, installPublicMcpFixture, request } from "../../fixtures/publicMcp"; const meta = { - title: "Assistant connections/Consent", + id: "assistant-connections-consent", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Consent", component: McpConnectPage, parameters: { layout: "padded", initialEntries: [`/mcp-connect/${request.id}`], docs: { description: { component: "Production consent page at /mcp-connect/:id. API fixtures are isolated. Successful consent stays in this preview; no OAuth credentials are issued." } } }, beforeEach: ({ parameters }) => installPublicMcpFixture(parameters.fixture), diff --git a/ui/storybook/stories/public-mcp-device.stories.tsx b/ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx similarity index 88% rename from ui/storybook/stories/public-mcp-device.stories.tsx rename to ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx index 93b92b0891c..758bf5e7d3c 100644 --- a/ui/storybook/stories/public-mcp-device.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/public-mcp-device.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; import { McpDevicePage } from "@/pages/McpConnect"; -import { installPublicMcpFixture } from "../fixtures/publicMcp"; +import { installPublicMcpFixture } from "../../fixtures/publicMcp"; const meta = { - title: "Assistant connections/Device authorization", component: McpDevicePage, + id: "assistant-connections-device-authorization", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Device authorization", component: McpDevicePage, parameters: { layout: "padded", fixture: {}, docs: { description: { component: "Production device consent. The assistant's private device code stays in its credential client; only the human verification code appears here. These isolated fixtures issue no credentials." } } }, args: { initialCode: "BCDF-GHJK" }, beforeEach: ({ parameters }) => installPublicMcpFixture(parameters.fixture), diff --git a/ui/storybook/stories/public-mcp-journey.stories.tsx b/ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx similarity index 94% rename from ui/storybook/stories/public-mcp-journey.stories.tsx rename to ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx index a4c43830ffb..9b4a481e1ad 100644 --- a/ui/storybook/stories/public-mcp-journey.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/public-mcp-journey.stories.tsx @@ -6,8 +6,8 @@ import { Link, Route, Routes } from "@/lib/router"; import { Browse } from "@/pages/apps/Browse"; import { AssistantConnection } from "@/pages/apps/AssistantConnection"; import { InstanceExperimentalSettings } from "@/pages/InstanceExperimentalSettings"; -import { PublicMcpPresenter } from "./public-mcp-presenter"; -import { connectedUser, installPublicMcpFixture } from "../fixtures/publicMcp"; +import { PublicMcpPresenter } from "../../support/public-mcp-presenter"; +import { connectedUser, installPublicMcpFixture } from "../../fixtures/publicMcp"; function ConnectionJourney({ assistant = "opencode" }: { assistant?: "codex" | "claude" | "opencode" | "other" }) { return @@ -21,7 +21,8 @@ function ConnectionJourney({ assistant = "opencode" }: { assistant?: "codex" | " } const meta = { - title: "Assistant connections/Start here", + id: "assistant-connections-start-here", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Start here", component: ConnectionJourney, parameters: { layout: "fullscreen", initialEntries: ["/PAP/apps"], fixture: { enabled: false, empty: true }, diff --git a/ui/storybook/stories/public-mcp-setting.stories.tsx b/ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx similarity index 90% rename from ui/storybook/stories/public-mcp-setting.stories.tsx rename to ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx index a6cb3716cc1..b0da6f0b013 100644 --- a/ui/storybook/stories/public-mcp-setting.stories.tsx +++ b/ui/storybook/stories/gateways-profiles/public-mcp-setting.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; import { InstanceExperimentalSettings } from "@/pages/InstanceExperimentalSettings"; -import { installPublicMcpFixture } from "../fixtures/publicMcp"; +import { installPublicMcpFixture } from "../../fixtures/publicMcp"; const meta = { - title: "Assistant connections/Experimental setting", component: InstanceExperimentalSettings, + id: "assistant-connections-experimental-setting", + title: "Apps, channels, and extensions/Tool gateways and access profiles/Assistant connections/Experimental setting", component: InstanceExperimentalSettings, parameters: { layout: "padded", initialEntries: ["/PAP/company/settings/instance/experimental"], docs: { description: { component: "The production Experimental page, including surrounding settings. Changes are local to this story." } } }, beforeEach: ({ parameters }) => installPublicMcpFixture(parameters.fixture), } satisfies Meta; diff --git a/ui/storybook/stories/blocked-inbox.stories.tsx b/ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx similarity index 98% rename from ui/storybook/stories/blocked-inbox.stories.tsx rename to ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx index b4a5841cd2d..727d3ce83a7 100644 --- a/ui/storybook/stories/blocked-inbox.stories.tsx +++ b/ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx @@ -6,7 +6,7 @@ import { BlockedInboxView } from "@/components/BlockedInboxView"; import { BlockedReasonChip } from "@/components/BlockedReasonChip"; import { defaultIssueFilterState } from "@/lib/issue-filters"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookIssues } from "../fixtures/paperclipData"; +import { storybookIssues } from "../../fixtures/paperclipData"; const companyId = "company-storybook"; const blockedViewDefaults = { @@ -267,7 +267,8 @@ function BlockedTabEmptyState() { } const meta = { - title: "Product/Inbox/Blocked tab", + id: "product-inbox-blocked-tab", + title: "Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab", component: BlockedTabSurface, parameters: { docs: { diff --git a/ui/storybook/stories/decision-card.stories.tsx b/ui/storybook/stories/inbox-search/decision-card.stories.tsx similarity index 98% rename from ui/storybook/stories/decision-card.stories.tsx rename to ui/storybook/stories/inbox-search/decision-card.stories.tsx index 6e051ad72d5..fa96bd89ed3 100644 --- a/ui/storybook/stories/decision-card.stories.tsx +++ b/ui/storybook/stories/inbox-search/decision-card.stories.tsx @@ -91,7 +91,8 @@ const shared = { }; const meta: Meta = { - title: "Decisions/DecisionCard", + id: "decisions-decisioncard", + title: "Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard", component: DecisionCard, render: (args) => (
diff --git a/ui/storybook/stories/decisions-desk.stories.tsx b/ui/storybook/stories/inbox-search/decisions-desk.stories.tsx similarity index 99% rename from ui/storybook/stories/decisions-desk.stories.tsx rename to ui/storybook/stories/inbox-search/decisions-desk.stories.tsx index a2faf41e8f2..8f8645dd134 100644 --- a/ui/storybook/stories/decisions-desk.stories.tsx +++ b/ui/storybook/stories/inbox-search/decisions-desk.stories.tsx @@ -444,7 +444,8 @@ const INTERACTION_AUDIENCE_ITEMS: AttentionItem[] = [ ]; const meta: Meta = { - title: "Pages/Decisions Desk", + id: "pages-decisions-desk", + title: "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/search.stories.tsx b/ui/storybook/stories/inbox-search/search.stories.tsx similarity index 99% rename from ui/storybook/stories/search.stories.tsx rename to ui/storybook/stories/inbox-search/search.stories.tsx index 4a3291df9a5..0d261a95416 100644 --- a/ui/storybook/stories/search.stories.tsx +++ b/ui/storybook/stories/inbox-search/search.stories.tsx @@ -39,7 +39,7 @@ import { CommandSeparator, } from "@/components/ui/command"; import { StatusBadge } from "@/components/StatusBadge"; -import { storybookAgents, storybookProjects, storybookIssues } from "../fixtures/paperclipData"; +import { storybookAgents, storybookProjects, storybookIssues } from "../../fixtures/paperclipData"; const agentsById = new Map(storybookAgents.map((agent) => [agent.id, agent])); @@ -716,7 +716,8 @@ function SearchStories() { } const meta = { - title: "Product/Search & Command K", + id: "product-search-command-k", + title: "Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K", component: SearchStories, parameters: { docs: { diff --git a/ui/storybook/stories/what-needs-me.stories.tsx b/ui/storybook/stories/inbox-search/what-needs-me.stories.tsx similarity index 99% rename from ui/storybook/stories/what-needs-me.stories.tsx rename to ui/storybook/stories/inbox-search/what-needs-me.stories.tsx index fca76fdc23d..292e363df0c 100644 --- a/ui/storybook/stories/what-needs-me.stories.tsx +++ b/ui/storybook/stories/inbox-search/what-needs-me.stories.tsx @@ -488,7 +488,8 @@ function Queue({ } const meta: Meta = { - title: "Pages/Decisions", + id: "pages-decisions", + title: "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions", component: Queue, parameters: { layout: "fullscreen" }, }; diff --git a/ui/storybook/stories/dev-ops-surfaces.stories.tsx b/ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx similarity index 95% rename from ui/storybook/stories/dev-ops-surfaces.stories.tsx rename to ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx index 6690392d15d..2bf35725d25 100644 --- a/ui/storybook/stories/dev-ops-surfaces.stories.tsx +++ b/ui/storybook/stories/instance-operations/dev-ops-surfaces.stories.tsx @@ -54,7 +54,8 @@ function DevOpsSurfacesStory() { } const meta = { - title: "Product/Dev Ops Surfaces", + id: "product-dev-ops-surfaces", + title: "Oversight and operation/Instance operations/Product/Dev Ops Surfaces", component: DevOpsSurfacesStory, parameters: { docs: { diff --git a/ui/storybook/stories/announcement-card.stories.tsx b/ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx similarity index 93% rename from ui/storybook/stories/announcement-card.stories.tsx rename to ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx index b684e28107d..5c0a53dba44 100644 --- a/ui/storybook/stories/announcement-card.stories.tsx +++ b/ui/storybook/stories/navigation-preferences/announcement-card.stories.tsx @@ -3,7 +3,8 @@ import { AnnouncementCard } from "@/components/AnnouncementCard"; import { announcementPreview, announcementAnimationPreview, announcementAnimationPreviewSrc } from "@/lib/announcement-preview"; const meta = { - title: "Announcements/AnnouncementCard", + id: "announcements-announcementcard", + title: "Oversight and operation/Navigation, profile, and announcements/Announcements/AnnouncementCard", component: AnnouncementCard, args: { announcement: announcementPreview, imageSrc: "/announcement-preview.svg", onDismiss: () => {} }, decorators: [(Story) =>
], diff --git a/ui/storybook/stories/mobile-task-tabs.stories.tsx b/ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx similarity index 97% rename from ui/storybook/stories/mobile-task-tabs.stories.tsx rename to ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx index b2f96dc95cd..39c5e0cc20b 100644 --- a/ui/storybook/stories/mobile-task-tabs.stories.tsx +++ b/ui/storybook/stories/navigation-preferences/mobile-task-tabs.stories.tsx @@ -48,7 +48,8 @@ function Prototype({ initialTabs = basicTabs, activeTabId = "plan" }: { initialT } const meta = { - title: "Prototypes/Task detail/Mobile tabs", + id: "prototypes-task-detail-mobile-tabs", + title: "Oversight and operation/Navigation, profile, and announcements/Prototypes/Task detail/Mobile tabs", parameters: { layout: "fullscreen", docs: { description: { component: "A task toolbar with a vertical open-tab overview. The current document stays readable; titles wrap in the overview, selection is explicit, close actions stay visible, and controls have 44px touch targets. Inspired by Apple’s toolbar and safe-area guidance: https://developer.apple.com/design/human-interface-guidelines/toolbars and https://developer.apple.com/design/human-interface-guidelines/layout." } }, diff --git a/ui/storybook/stories/navigation-layout.stories.tsx b/ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx similarity index 98% rename from ui/storybook/stories/navigation-layout.stories.tsx rename to ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx index 4eca73816f6..05316942295 100644 --- a/ui/storybook/stories/navigation-layout.stories.tsx +++ b/ui/storybook/stories/navigation-preferences/navigation-layout.stories.tsx @@ -39,7 +39,7 @@ import { storybookIssues, storybookProjects, storybookSidebarBadges, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; function Section({ eyebrow, @@ -332,7 +332,8 @@ function NavigationLayoutStories() { } const meta = { - title: "Product/Navigation & Layout", + id: "product-navigation-layout", + title: "Oversight and operation/Navigation, profile, and announcements/Product/Navigation & Layout", component: NavigationLayoutStories, // Sidebar mounts PluginLauncherOutlet unconditionally once a company is // selected; without this provider the story raced company selection and diff --git a/ui/storybook/stories/onboarding-agent-arc.stories.tsx b/ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx similarity index 99% rename from ui/storybook/stories/onboarding-agent-arc.stories.tsx rename to ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx index 8671277b425..47189737929 100644 --- a/ui/storybook/stories/onboarding-agent-arc.stories.tsx +++ b/ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx @@ -12,11 +12,11 @@ import { STORYBOOK_COMPANY_ID, clearOnboardingDraft, seedOnboardingDraft, -} from "../fixtures/onboardingDraft"; +} from "../../fixtures/onboardingDraft"; import { resetOnboardingFixtureState, setOnboardingFixtureState, -} from "../fixtures/onboardingEnvironment"; +} from "../../fixtures/onboardingEnvironment"; /** * The onboarding wizard's agent arc: create the agent, connect a model, review. @@ -32,7 +32,8 @@ import { * reached at all. */ const meta = { - title: "Onboarding/Agent arc", + id: "onboarding-agent-arc", + title: "Getting started and access/Onboarding and first work/Onboarding/Agent arc", parameters: { layout: "centered" }, } satisfies Meta; diff --git a/ui/storybook/stories/onboarding-character.stories.tsx b/ui/storybook/stories/onboarding/onboarding-character.stories.tsx similarity index 90% rename from ui/storybook/stories/onboarding-character.stories.tsx rename to ui/storybook/stories/onboarding/onboarding-character.stories.tsx index ca6d8c462e6..b69cce348da 100644 --- a/ui/storybook/stories/onboarding-character.stories.tsx +++ b/ui/storybook/stories/onboarding/onboarding-character.stories.tsx @@ -1,11 +1,12 @@ import { useState, type ReactNode } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { AGENT_PALETTE_IDS, appearanceForPalette, type AgentPaletteId } from "@paperclipai/shared"; -import { OnboardingCharacter } from "../../src/components/onboarding/OnboardingCharacter"; -import { Button } from "../../src/components/ui/button"; +import { OnboardingCharacter } from "../../../src/components/onboarding/OnboardingCharacter"; +import { Button } from "../../../src/components/ui/button"; const meta = { - title: "Onboarding/Character", + id: "onboarding-character", + title: "Getting started and access/Onboarding and first work/Onboarding/Character", component: OnboardingCharacter, args: { appearance: appearanceForPalette("bubblegum-sky"), awake: false }, parameters: { docs: { description: { component: "The onboarding hero: the persona character (the studio's App export) on the shared ClipLab v0.2.0 engine. Gray and dozing until Review, where it plays the one-shot sleepy → wink → idle transition while its palette fades in, then settles into the idle loop. Reduced motion skips the sequence." } } }, diff --git a/ui/storybook/stories/onboarding-connect-model.stories.tsx b/ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx similarity index 95% rename from ui/storybook/stories/onboarding-connect-model.stories.tsx rename to ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx index b3c15e07399..065bc194575 100644 --- a/ui/storybook/stories/onboarding-connect-model.stories.tsx +++ b/ui/storybook/stories/onboarding/onboarding-connect-model.stories.tsx @@ -18,7 +18,8 @@ import { ConnectModelPreview } from "@/components/onboarding/ConnectModelPreview * it reads as one row changing its terms. */ const meta = { - title: "Onboarding/Connect a model", + id: "onboarding-connect-a-model", + title: "Getting started and access/Onboarding and first work/Onboarding/Connect a model", parameters: { layout: "centered" }, } satisfies Meta; diff --git a/ui/storybook/stories/overview.stories.tsx b/ui/storybook/stories/overview.stories.tsx deleted file mode 100644 index c95b2126a69..00000000000 --- a/ui/storybook/stories/overview.stories.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { - BookOpen, - Bot, - CheckCircle2, - FlaskConical, - FolderKanban, - FormInput, - Layers3, - LayoutDashboard, - ListTodo, - MessageSquare, - PanelLeft, - Route, - ShieldCheck, - Wallet, -} from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; - -const storyGroups = [ - { - title: "Foundations", - icon: Layers3, - stories: "Buttons, badges, form controls, tabs, cards, dialogs, overlays", - why: "Baseline tokens and primitives before product components add state.", - }, - { - title: "Control Plane Surfaces", - icon: ShieldCheck, - stories: "Issue rows, approvals, budget cards, activity rows, metrics", - why: "The V1 board UI depends on these dense operational patterns staying legible.", - }, - { - title: "UX Labs", - icon: FlaskConical, - stories: "Issue chat, run transcripts, invite/access flows", - why: "The old `/tests/ux/*` pages are fixture-backed Storybook stories now.", - }, - { - title: "Navigation & Layout", - icon: PanelLeft, - stories: "Sidebar, breadcrumbs, command palette, company rail, mobile nav", - why: "Navigation chrome frames every board interaction and needs mobile parity.", - }, - { - title: "Agent Management", - icon: Bot, - stories: "Agent properties, config forms, icon picker, action buttons", - why: "Agent lifecycle is the primary governance surface for operators.", - }, - { - title: "Issue Management", - icon: ListTodo, - stories: "Issue lists, filters, properties, documents, run ledger, workspace cards", - why: "Issues are the core work unit — every view state matters for scan speed.", - }, - { - title: "Forms & Editors", - icon: FormInput, - stories: "Markdown editor, JSON schema forms, env vars, schedule editor, pickers", - why: "Rich editors need isolated review for empty, filled, and validation states.", - }, - { - title: "Budget & Finance", - icon: Wallet, - stories: "Incident cards, provider quotas, biller spend, subscription panels", - why: "Financial controls are safety-critical and need threshold state coverage.", - }, - { - title: "Dialogs & Modals", - icon: LayoutDashboard, - stories: "New issue/agent/goal/project dialogs, diff modal, image gallery", - why: "Dialogs interrupt flow — they must be scannable and self-explanatory.", - }, - { - title: "Projects & Goals", - icon: FolderKanban, - stories: "Project properties, workspace cards, goal trees, runtime controls", - why: "Hierarchical views (goals, projects, workspaces) need expand/collapse coverage.", - }, - { - title: "Chat & Comments", - icon: MessageSquare, - stories: "Comment threads, run chat, issue chat with timeline events", - why: "Threaded conversations mix agent/user/system authors and need density review.", - }, -]; - -const coverageRows = [ - ["System primitives", "Covered", "State matrix across size, variant, disabled, icon, and overlay behavior"], - ["Status language", "Covered", "Issue/agent lifecycle badges, priorities, quota thresholds, and empty states"], - ["Task surfaces", "Covered", "Inbox-style rows with unread, selected, archive, and trailing metadata states"], - ["Governance", "Covered", "Pending, revision-requested, approved, and budget-specific approval payloads"], - ["Budget controls", "Covered", "Healthy, warning, hard-stop, compact, plain, and editable card variants"], - ["Execution UX", "Covered", "Run transcript detail, live widget, dashboard card, streaming and settled views"], - ["Invite UX", "Covered", "Fixture-backed access roles, invite landing, pending, accepted, expired, and error states"], - ["Navigation & layout", "Planned", "Sidebar, breadcrumbs, command palette, company rail, mobile nav"], - ["Agent management", "Planned", "Agent properties, config forms, icon picker, action buttons, active panel"], - ["Issue management", "Planned", "Issue lists, filters, properties, documents, run ledger, workspace cards"], - ["Forms & editors", "Planned", "Markdown editor, JSON schema, env vars, schedule editor, pickers"], - ["Budget & finance", "Planned", "Incident cards, provider quotas, biller spend, subscription panels"], - ["Dialogs & modals", "Planned", "New issue/agent/goal/project dialogs, diff modal, image gallery"], - ["Projects & goals", "Planned", "Project properties, workspace cards, goal trees, runtime controls"], - ["Chat & comments", "Covered", "Comment threads, run chat, issue chat with timeline events"], - ["Data viz & misc", "Planned", "Activity charts, kanban, filter bar, live widget, onboarding, skeletons"], - ["Full app pages", "Deferred", "API-driven route stories after page data loaders can be fixture-injected"], -]; - -function StorybookGuide() { - return ( -
-
-
-
-
-
- - Paperclip Storybook -
-

- Board UI stories for real control-plane states -

-

- This Storybook is organized as a review workspace for Paperclip's operator UI: primitives first, - product surfaces second, and the former UX test routes as isolated fixture-backed stories. -

-
-
- React 19 - Vite - Tailwind 4 - Fixture backed -
-
-
- -
- {storyGroups.map((group) => { - const Icon = group.icon; - return ( - - -
- -
- {group.title} - {group.stories} -
- - {group.why} - -
- ); - })} -
- -
-
-
- - Coverage Map -
-
-
- {coverageRows.map(([area, status, detail]) => ( -
-
{area}
-
- - {status === "Covered" ? : null} - {status === "Planned" ? : null} - {status} - -
-
{detail}
-
- ))} -
-
-
-
- ); -} - -const meta = { - title: "Overview/Storybook Guide", - component: StorybookGuide, - parameters: { - docs: { - description: { - component: - "The overview story explains the local organization and the coverage contract for Paperclip's Storybook.", - }, - }, - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const CoverageGuide: Story = {}; diff --git a/ui/storybook/stories/overview/overview.stories.tsx b/ui/storybook/stories/overview/overview.stories.tsx new file mode 100644 index 00000000000..4da748cfce2 --- /dev/null +++ b/ui/storybook/stories/overview/overview.stories.tsx @@ -0,0 +1,93 @@ +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"}

    +
  • + ))} +
+
+ ))} +
+ ); +} + +const meta = { + id: "overview-storybook-guide", + title: "Overview/Storybook Guide", + component: StorybookGuide, + 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; +export default meta; +type Story = StoryObj; +export const CoverageGuide: Story = {}; diff --git a/ui/storybook/stories/new-project-repos.stories.tsx b/ui/storybook/stories/projects/new-project-repos.stories.tsx similarity index 92% rename from ui/storybook/stories/new-project-repos.stories.tsx rename to ui/storybook/stories/projects/new-project-repos.stories.tsx index 3d3bc78466f..74179ac965e 100644 --- a/ui/storybook/stories/new-project-repos.stories.tsx +++ b/ui/storybook/stories/projects/new-project-repos.stories.tsx @@ -1,11 +1,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { NewProjectPrototype } from "../prototypes/project-repos/ProjectReposPrototype"; +import { NewProjectPrototype } from "../../prototypes/project-repos/ProjectReposPrototype"; -import { crowdedRepoIds, reviewViewports } from "../prototypes/project-repos/fixtures"; +import { crowdedRepoIds, reviewViewports } from "../../prototypes/project-repos/fixtures"; const meta = { - title: "Proposals/Project repos/New project", + id: "proposals-project-repos-new-project", + title: "Projects and execution/Projects and repositories/Proposals/Project repos/New project", component: NewProjectPrototype, tags: ["!autodocs"], parameters: { layout: "fullscreen", viewport: { options: reviewViewports }, docs: { description: { component: "Interactive design proposal only. The project name starts empty and focused, with an outlined folder icon. The source repo region scrolls while the title, name, and actions remain visible. GitHub setup uses the existing ConnectionSetupFlow; Continue to GitHub simulates a successful provider return. Creation and repository changes stay in memory." } } }, diff --git a/ui/storybook/stories/project-execution-workspace-strategy.stories.tsx b/ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx similarity index 92% rename from ui/storybook/stories/project-execution-workspace-strategy.stories.tsx rename to ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx index c47bd8ed7a6..2cfdbe3effb 100644 --- a/ui/storybook/stories/project-execution-workspace-strategy.stories.tsx +++ b/ui/storybook/stories/projects/project-execution-workspace-strategy.stories.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import type { Project } from "@paperclipai/shared"; import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "@/components/ProjectProperties"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookProjects } from "../fixtures/paperclipData"; +import { storybookProjects } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const boardProject = storybookProjects.find((project) => project.id === "project-board-ui") ?? storybookProjects[0]!; @@ -28,7 +28,8 @@ function Hydrate({ children }: { children: ReactNode }) { } const meta: Meta = { - title: "Workspaces/Project runtime provision command", + id: "workspaces-project-runtime-provision-command", + title: "Projects and execution/Projects and repositories/Workspaces/Project runtime provision command", component: ProjectProperties, parameters: { layout: "padded" }, }; diff --git a/ui/storybook/stories/project-repos-configuration.stories.tsx b/ui/storybook/stories/projects/project-repos-configuration.stories.tsx similarity index 87% rename from ui/storybook/stories/project-repos-configuration.stories.tsx rename to ui/storybook/stories/projects/project-repos-configuration.stories.tsx index 6015a5475a3..1149d465441 100644 --- a/ui/storybook/stories/project-repos-configuration.stories.tsx +++ b/ui/storybook/stories/projects/project-repos-configuration.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ProjectConfigurationPrototype } from "../prototypes/project-repos/ProjectConfigurationPage"; +import { ProjectConfigurationPrototype } from "../../prototypes/project-repos/ProjectConfigurationPage"; -import { crowdedRepoIds, reviewViewports } from "../prototypes/project-repos/fixtures"; +import { crowdedRepoIds, reviewViewports } from "../../prototypes/project-repos/fixtures"; const meta = { - title: "Proposals/Project repos/Configuration", + id: "proposals-project-repos-configuration", + title: "Projects and execution/Projects and repositories/Proposals/Project repos/Configuration", component: ProjectConfigurationPrototype, tags: ["!autodocs"], parameters: { layout: "fullscreen", viewport: { options: reviewViewports }, docs: { description: { component: "Full Onboarding configuration page, based on the Bull staging reference: sidebar, header, tabs, actual ProjectProperties, and the proposed source repo editor in the Codebase slot. Add/remove repos, save or discard, connect another GitHub account, and preserve existing text URLs. All writes and provider sign-in are simulated in Storybook." } } }, diff --git a/ui/storybook/stories/projects-goals-workspaces.stories.tsx b/ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx similarity index 99% rename from ui/storybook/stories/projects-goals-workspaces.stories.tsx rename to ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx index c10f5958366..7eb9e5fbd2f 100644 --- a/ui/storybook/stories/projects-goals-workspaces.stories.tsx +++ b/ui/storybook/stories/projects/projects-goals-workspaces.stories.tsx @@ -27,7 +27,7 @@ import { storybookIssues, storybookProjectWorkspaces, storybookProjects, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const boardProject = storybookProjects.find((project) => project.id === "project-board-ui") ?? storybookProjects[0]!; @@ -497,7 +497,8 @@ function ProjectsGoalsWorkspacesStories() { } const meta = { - title: "Product/Projects Goals Workspaces", + id: "product-projects-goals-workspaces", + title: "Projects and execution/Projects and repositories/Product/Projects Goals Workspaces", component: ProjectsGoalsWorkspacesStories, parameters: { docs: { diff --git a/ui/storybook/stories/agent-chat-unanswered-questions.stories.tsx b/ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx similarity index 93% rename from ui/storybook/stories/agent-chat-unanswered-questions.stories.tsx rename to ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx index 6d25d088789..f292721632b 100644 --- a/ui/storybook/stories/agent-chat-unanswered-questions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/agent-chat-unanswered-questions.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; -import { QuestionChat, question } from "../fixtures/unansweredQuestionChat"; +import { QuestionChat, question } from "../../fixtures/unansweredQuestionChat"; -const meta = { title: "Chat & Comments/Agent Chat Unanswered Questions", parameters: { layout: "fullscreen" }, component: QuestionChat, +const meta = { id: "chat-comments-agent-chat-unanswered-questions", + title: "Tasks and conversations/Questions and approvals/Chat & Comments/Agent Chat Unanswered Questions", parameters: { layout: "fullscreen" }, component: QuestionChat, beforeEach: () => { localStorage.removeItem(`paperclip:task-question-dismissals:user-board:${question.issueId}`); for (const key of Object.keys(localStorage)) { diff --git a/ui/storybook/stories/composer-question-flow.stories.tsx b/ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx similarity index 98% rename from ui/storybook/stories/composer-question-flow.stories.tsx rename to ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx index 4fd6af0f0e4..34ec7fba1e1 100644 --- a/ui/storybook/stories/composer-question-flow.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/composer-question-flow.stories.tsx @@ -85,7 +85,8 @@ function InteractiveComposerQuestions() { } const meta = { - title: "Composer/Question flow", + id: "composer-question-flow", + title: "Tasks and conversations/Questions and approvals/Composer/Question flow", component: InteractiveComposerQuestions, parameters: { layout: "padded", diff --git a/ui/storybook/stories/connection-reviews.stories.tsx b/ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx similarity index 98% rename from ui/storybook/stories/connection-reviews.stories.tsx rename to ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx index 360435d1f9b..825c61b0a8b 100644 --- a/ui/storybook/stories/connection-reviews.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/connection-reviews.stories.tsx @@ -17,7 +17,7 @@ import { toolsApi } from "@/api/tools"; import { issuesApi } from "@/api/issues"; import { ReviewQueueCard } from "@/pages/apps/ReviewQueueCard"; import type { ToolActionRequestListItem } from "@paperclipai/shared"; -import { storybookAgentMap } from "../fixtures/paperclipData"; +import { storybookAgentMap } from "../../fixtures/paperclipData"; const readRequest: RequestConfirmationInteraction = { ...pendingToolActionWriteInteraction, @@ -44,7 +44,8 @@ const readRequest: RequestConfirmationInteraction = { }, }; const meta = { - title: "Chat & Comments/Connection Reviews", + id: "chat-comments-connection-reviews", + title: "Tasks and conversations/Questions and approvals/Chat & Comments/Connection Reviews", parameters: { layout: "fullscreen" }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/issue-plan-decompositions.stories.tsx b/ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx similarity index 94% rename from ui/storybook/stories/issue-plan-decompositions.stories.tsx rename to ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx index e53f33c44f4..0e3ab134d01 100644 --- a/ui/storybook/stories/issue-plan-decompositions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/issue-plan-decompositions.stories.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import type { AcceptedPlanDecompositionSummary } from "@paperclipai/shared"; import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookAgentMap } from "../fixtures/paperclipData"; +import { storybookAgentMap } from "../../fixtures/paperclipData"; const issueId = "issue-plan-decomposition-story"; const issueIdentifier = "PAP-6831"; @@ -82,7 +82,8 @@ function HydratedSection({ } const meta = { - title: "Issue Detail/Plan Decompositions", + id: "issue-detail-plan-decompositions", + title: "Tasks and conversations/Questions and approvals/Issue Detail/Plan Decompositions", component: HydratedSection, args: { decompositions: [], diff --git a/ui/storybook/stories/issue-thread-interactions.stories.tsx b/ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx similarity index 99% rename from ui/storybook/stories/issue-thread-interactions.stories.tsx rename to ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx index 371082cbdab..d72c03559b7 100644 --- a/ui/storybook/stories/issue-thread-interactions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/issue-thread-interactions.stories.tsx @@ -79,7 +79,7 @@ import type { RequestItemVerdictValue, SuggestTasksInteraction, } from "@/lib/issue-thread-interactions"; -import { storybookAgentMap } from "../fixtures/paperclipData"; +import { storybookAgentMap } from "../../fixtures/paperclipData"; const boardUserLabels = new Map([ [issueThreadInteractionFixtureMeta.currentUserId, "Riley Board"], @@ -432,7 +432,8 @@ function AutoOpenDeclineRequestConfirmationCard({ } const meta = { - title: "Chat & Comments/Issue Thread Interactions", + id: "chat-comments-issue-thread-interactions", + title: "Tasks and conversations/Questions and approvals/Chat & Comments/Issue Thread Interactions", parameters: { docs: { description: { diff --git a/ui/storybook/stories/rich-acp-interactions.stories.tsx b/ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx similarity index 98% rename from ui/storybook/stories/rich-acp-interactions.stories.tsx rename to ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx index 5f17004f7d5..929b9bbd2cb 100644 --- a/ui/storybook/stories/rich-acp-interactions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/rich-acp-interactions.stories.tsx @@ -19,7 +19,7 @@ import type { AskUserQuestionsInteraction, RequestConfirmationInteraction, } from "@/lib/issue-thread-interactions"; -import { storybookAgentMap } from "../fixtures/paperclipData"; +import { storybookAgentMap } from "../../fixtures/paperclipData"; // Production components and production projection, with callbacks kept in memory. // No provider, runner, or control-plane mutation is made by these stories. @@ -181,7 +181,8 @@ function ProviderNotice() { } const meta = { - title: "Chat & Comments/Rich ACP interactions", + id: "chat-comments-rich-acp-interactions", + title: "Tasks and conversations/Questions and approvals/Chat & Comments/Rich ACP interactions", parameters: { layout: "fullscreen", docs: { description: { component: "Credential-free production UI fixtures. Callback receipts establish UI payload fidelity only; they do not claim provider execution or server persistence." } } }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx b/ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx similarity index 83% rename from ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx rename to ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx index 5fe19d676e0..2f35277bfc9 100644 --- a/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/task-chat-interaction-above-composer.stories.tsx @@ -19,8 +19,8 @@ import { pendingToolActionWriteInteraction, issueThreadInteractionFixtureMeta, } from "@/fixtures/issueThreadInteractionFixtures"; -import { composerAgentAppearance, composerAgents } from "../prototypes/composer-model-picker/fixtures"; -import { storybookAgentMap } from "../fixtures/paperclipData"; +import { composerAgentAppearance, composerAgents } from "../../prototypes/composer-model-picker/fixtures"; +import { storybookAgentMap } from "../../fixtures/paperclipData"; const agentMap = new Map(composerAgents.map((agent) => [agent.id, { id: agent.id, @@ -77,7 +77,10 @@ function InteractionAboveComposer({ interaction, mobile = false }: { interaction ); const composer = ( setMessages((current) => [...current, body])} + onAdd={async (body) => { + setMessages((current) => [...current, body]); + if (interaction.kind === "ask_user_questions") setOpen(false); + }} workMode={workMode} onWorkModeChange={setWorkMode} enableReassign @@ -96,7 +99,9 @@ function InteractionAboveComposer({ interaction, mobile = false }: { interaction hideSkip: interaction.kind !== "ask_user_questions" && interaction.kind !== "request_item_verdicts", hideLabel: interaction.kind === "request_confirmation" && Boolean(interaction.payload.toolAction), } : null} - pendingTakeover={pending ? { count: 1, label: "1 pending input", onOpen: () => setOpen(true) } : null} + pendingTakeover={pending && interaction.kind !== "ask_user_questions" + ? { count: 1, label: "1 pending input", onOpen: () => setOpen(true) } + : null} /> ); @@ -138,12 +143,13 @@ function InteractionAboveComposer({ interaction, mobile = false }: { interaction } const meta = { - title: "Composer/Interaction above composer", + id: "composer-interaction-above-composer", + title: "Tasks and conversations/Questions and approvals/Composer/Interaction above composer", component: InteractionAboveComposer, parameters: { layout: "fullscreen", options: { showPanel: false }, - docs: { description: { component: "The production task interaction card and composer shown together. Dismissal leaves a pending indicator; Skip or a decision resolves the card. The editor remains available throughout." } }, + docs: { description: { component: "The production task interaction card and composer shown together. Dismissed questions do not contribute a composer pending indicator; other unresolved inputs retain one. Skip or a decision resolves the card. This local composition does not model persisted dismissal or the thread's history entry; use Task Chat Unanswered Questions for that fixture journey." } }, }, args: { interaction: pendingAskUserQuestionsInteraction, mobile: false }, } satisfies Meta; @@ -154,11 +160,31 @@ export const AskUserQuestions: Story = {}; export const AskUserQuestionsAndSend: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const editor = canvas.getByTestId("mdx-editor"); + const editor = canvas.getByRole("textbox", { name: "editable markdown" }); await userEvent.click(editor); await userEvent.type(editor, "I can keep working while I decide."); await userEvent.click(canvas.getByRole("button", { name: "Send" })); await expect(canvas.getByText("I can keep working while I decide.")).toBeVisible(); + await expect(canvas.queryByTestId("task-chat-composer-takeover")).not.toBeInTheDocument(); + await expect(canvas.queryByRole("button", { name: /1 pending input/i })).not.toBeInTheDocument(); + }, +}; +export const DismissQuestionWithoutPendingBadge: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /^Dismiss / })); + await expect(canvas.queryByTestId("task-chat-composer-takeover")).not.toBeInTheDocument(); + await expect(canvas.queryByRole("button", { name: /1 pending input/i })).not.toBeInTheDocument(); + await expect(canvas.getByRole("textbox", { name: "editable markdown" })).toBeVisible(); + }, +}; +export const DismissConfirmationKeepsPendingBadge: Story = { + args: { interaction: genericPendingRequestConfirmationInteraction }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /^Dismiss / })); + await expect(canvas.getByRole("button", { name: /1 pending input/i })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: /1 pending input/i })); await expect(canvas.getByTestId("task-chat-composer-takeover")).toBeVisible(); }, }; diff --git a/ui/storybook/stories/task-chat-unanswered-questions.stories.tsx b/ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx similarity index 89% rename from ui/storybook/stories/task-chat-unanswered-questions.stories.tsx rename to ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx index a5b698086f3..286a3090528 100644 --- a/ui/storybook/stories/task-chat-unanswered-questions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within, waitFor } from "storybook/test"; -import { QuestionChat } from "../fixtures/unansweredQuestionChat"; +import { QuestionChat } from "../../fixtures/unansweredQuestionChat"; const meta = { - title: "Chat & Comments/Task Chat Unanswered Questions", + id: "chat-comments-task-chat-unanswered-questions", + title: "Tasks and conversations/Questions and approvals/Chat & Comments/Task Chat Unanswered Questions", parameters: { layout: "fullscreen" }, component: QuestionChat, args: { conversationMode: false }, diff --git a/ui/storybook/stories/cloud-access-recovery.stories.tsx b/ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx similarity index 85% rename from ui/storybook/stories/cloud-access-recovery.stories.tsx rename to ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx index 4d01137a244..9db96e84bd6 100644 --- a/ui/storybook/stories/cloud-access-recovery.stories.tsx +++ b/ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { CloudAccessError } from "@/components/CloudAccessGate"; const meta: Meta = { - title: "App/Connection recovery", + id: "app-connection-recovery", + title: "Projects and execution/Recovery/App/Connection recovery", component: CloudAccessError, parameters: { layout: "fullscreen" }, args: { temporary: true, retrying: false, onRetry: () => undefined }, diff --git a/ui/storybook/stories/execution-blocker-notice.stories.tsx b/ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx similarity index 91% rename from ui/storybook/stories/execution-blocker-notice.stories.tsx rename to ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx index e97cc80ada3..138e3cdb26c 100644 --- a/ui/storybook/stories/execution-blocker-notice.stories.tsx +++ b/ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx @@ -8,7 +8,8 @@ client.setQueryData(queryKeys.issues.runs("storybook-recovery"), [ { runId: "stopped-run", agentId: "agent", status: "failed" }, ]); const meta = { - title: "Task chat/Recovery notice", + id: "task-chat-recovery-notice", + title: "Projects and execution/Recovery/Task chat/Recovery notice", component: ExecutionBlockerNotice, decorators: [Story =>
], args: { diff --git a/ui/storybook/stories/execution-recovery.stories.tsx b/ui/storybook/stories/recovery/execution-recovery.stories.tsx similarity index 98% rename from ui/storybook/stories/execution-recovery.stories.tsx rename to ui/storybook/stories/recovery/execution-recovery.stories.tsx index 609f5456c62..b057457b4bc 100644 --- a/ui/storybook/stories/execution-recovery.stories.tsx +++ b/ui/storybook/stories/recovery/execution-recovery.stories.tsx @@ -6,7 +6,7 @@ import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn"; import { TaskChatLiveRunPill } from "@/components/task-chat/TaskChatLiveRunPill"; import { Textarea } from "@/components/ui/textarea"; import { queryKeys } from "@/lib/queryKeys"; -import { createIssue, storybookLiveRuns } from "../fixtures/paperclipData"; +import { createIssue, storybookLiveRuns } from "../../fixtures/paperclipData"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ExecutionProjection } from "@paperclipai/shared"; import { expect, userEvent, within } from "storybook/test"; @@ -60,7 +60,8 @@ function QuietExecution({ execution }: { execution: ExecutionProjection }) { ); } const meta = { - title: "Tasks/Execution recovery", + id: "tasks-execution-recovery", + title: "Projects and execution/Recovery/Tasks/Execution recovery", component: QuietExecution, args: { execution: base }, decorators: [ diff --git a/ui/storybook/stories/issue-blocked-notice.stories.tsx b/ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx similarity index 98% rename from ui/storybook/stories/issue-blocked-notice.stories.tsx rename to ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx index a84bed3544a..e13cb2a9f64 100644 --- a/ui/storybook/stories/issue-blocked-notice.stories.tsx +++ b/ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx @@ -33,7 +33,8 @@ function Frame({ label, children }: { label: string; children: React.ReactNode } } const meta = { - title: "Product/Issue/Blocked notice", + id: "product-issue-blocked-notice", + title: "Projects and execution/Recovery/Product/Issue/Blocked notice", component: IssueBlockedNotice, // Each story supplies its own props via `render`; this default satisfies the // component's one required prop (`blockers`) so the story args type-checks. diff --git a/ui/storybook/stories/model-capacity-retry.stories.tsx b/ui/storybook/stories/recovery/model-capacity-retry.stories.tsx similarity index 89% rename from ui/storybook/stories/model-capacity-retry.stories.tsx rename to ui/storybook/stories/recovery/model-capacity-retry.stories.tsx index 4ef94229a7d..4385292d116 100644 --- a/ui/storybook/stories/model-capacity-retry.stories.tsx +++ b/ui/storybook/stories/recovery/model-capacity-retry.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { IssueScheduledRetryCard } from "@/components/IssueScheduledRetryCard"; const meta = { - title: "Tasks/Model capacity retry", + id: "tasks-model-capacity-retry", + title: "Projects and execution/Recovery/Tasks/Model capacity retry", component: IssueScheduledRetryCard, decorators: [Story =>
], args: { diff --git a/ui/storybook/stories/recovery-notice-preview.stories.tsx b/ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx similarity index 91% rename from ui/storybook/stories/recovery-notice-preview.stories.tsx rename to ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx index a5f5c872484..5a4e4de344c 100644 --- a/ui/storybook/stories/recovery-notice-preview.stories.tsx +++ b/ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx @@ -1,9 +1,10 @@ import { expect, userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RecoveryNoticePreview } from "../prototypes/recovery-notice/RecoveryNoticePreview"; +import { RecoveryNoticePreview } from "../../prototypes/recovery-notice/RecoveryNoticePreview"; const meta = { - title: "Design previews/Recovery notice", + id: "design-previews-recovery-notice", + title: "Projects and execution/Recovery/Design previews/Recovery notice", component: RecoveryNoticePreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/scheduled-retry.stories.tsx b/ui/storybook/stories/recovery/scheduled-retry.stories.tsx similarity index 97% rename from ui/storybook/stories/scheduled-retry.stories.tsx rename to ui/storybook/stories/recovery/scheduled-retry.stories.tsx index a18e72c06d7..4a9acd8f5df 100644 --- a/ui/storybook/stories/scheduled-retry.stories.tsx +++ b/ui/storybook/stories/recovery/scheduled-retry.stories.tsx @@ -6,7 +6,7 @@ import { storybookExecutionWorkspaces, storybookIssueDocuments, storybookIssues, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const issueDocumentSummaries = storybookIssueDocuments.map(({ body: _body, ...summary }) => summary); @@ -144,7 +144,8 @@ function ScheduledRetrySurfaceStories() { } const meta = { - title: "Product/Issue Scheduled retry surfaces", + id: "product-issue-scheduled-retry-surfaces", + title: "Projects and execution/Recovery/Product/Issue Scheduled retry surfaces", component: ScheduledRetrySurfaceStories, parameters: { docs: { diff --git a/ui/storybook/stories/source-issue-recovery.stories.tsx b/ui/storybook/stories/recovery/source-issue-recovery.stories.tsx similarity index 99% rename from ui/storybook/stories/source-issue-recovery.stories.tsx rename to ui/storybook/stories/recovery/source-issue-recovery.stories.tsx index 6687556f838..fe49dfc09b9 100644 --- a/ui/storybook/stories/source-issue-recovery.stories.tsx +++ b/ui/storybook/stories/recovery/source-issue-recovery.stories.tsx @@ -5,7 +5,7 @@ import { Eye, ExternalLink, OctagonAlert, RefreshCw, TriangleAlert } from "lucid import { IssueRecoveryActionCard } from "@/components/IssueRecoveryActionCard"; import { IssueRow } from "@/components/IssueRow"; import { IssueBlockedNotice } from "@/components/IssueBlockedNotice"; -import { storybookAgentMap, storybookAgents, createIssue } from "../fixtures/paperclipData"; +import { storybookAgentMap, storybookAgents, createIssue } from "../../fixtures/paperclipData"; const claudeAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("claude")) ?? storybookAgents[0]!; const codexAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("codex")) ?? storybookAgents[0]!; @@ -574,7 +574,8 @@ function InboxRowPanel() { } const meta = { - title: "Paperclip/Source Issue Recovery", + id: "paperclip-source-issue-recovery", + title: "Projects and execution/Recovery/Paperclip/Source Issue Recovery", component: AllStatesPanel, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/source-resolved-fold.stories.tsx b/ui/storybook/stories/recovery/source-resolved-fold.stories.tsx similarity index 98% rename from ui/storybook/stories/source-resolved-fold.stories.tsx rename to ui/storybook/stories/recovery/source-resolved-fold.stories.tsx index ff39164c370..ba714b96e8e 100644 --- a/ui/storybook/stories/source-resolved-fold.stories.tsx +++ b/ui/storybook/stories/recovery/source-resolved-fold.stories.tsx @@ -54,7 +54,8 @@ function DefaultPanel() { } const meta = { - title: "Paperclip/Source-resolved Fold", + id: "paperclip-source-resolved-fold", + title: "Projects and execution/Recovery/Paperclip/Source-resolved Fold", component: DefaultPanel, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/workspace-branch-recovery/01-component.stories.tsx b/ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx similarity index 90% rename from ui/storybook/stories/workspace-branch-recovery/01-component.stories.tsx rename to ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx index 8e2fdd6d576..cdc9698d708 100644 --- a/ui/storybook/stories/workspace-branch-recovery/01-component.stories.tsx +++ b/ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; import { WorkspaceBaseRefRecoveryNotice } from "@/components/WorkspaceBaseRefRecoveryNotice"; -import { evidence, simulateRepair } from "./fixtures"; +import { evidence, simulateRepair } from "../../../support/workspace-branch-recovery/fixtures"; const meta = { - title: "Workspace branch recovery/01 Repair card", + id: "workspace-branch-recovery-01-repair-card", + title: "Projects and execution/Recovery/Workspace branch recovery/01 Repair card", component: WorkspaceBaseRefRecoveryNotice, args: { ...evidence, onRepair: simulateRepair }, parameters: { layout: "fullscreen", docs: { description: { component: "Proposed inline repair using the reusable component. Repository data and save/retry responses are simulated here; this story does not change a live task. The default-branch action appears only with a verified repository default." } } }, diff --git a/ui/storybook/stories/workspace-branch-recovery/02-task-journey.stories.tsx b/ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx similarity index 92% rename from ui/storybook/stories/workspace-branch-recovery/02-task-journey.stories.tsx rename to ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx index 48f827ca789..f30e7a75a23 100644 --- a/ui/storybook/stories/workspace-branch-recovery/02-task-journey.stories.tsx +++ b/ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx @@ -8,7 +8,7 @@ import { WorkspaceBaseRefRecoveryNotice } from "@/components/WorkspaceBaseRefRec import { StatusBadge } from "@/components/StatusBadge"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; -import { evidence, simulateRepair } from "./fixtures"; +import { evidence, simulateRepair } from "../../../support/workspace-branch-recovery/fixtures"; function TaskConversation() { const [retried, setRetried] = useState(false); @@ -33,7 +33,8 @@ function Journey() { return }>} />; } const meta = { - title: "Workspace branch recovery/02 Task journey", component: Journey, + id: "workspace-branch-recovery-02-task-journey", + title: "Projects and execution/Recovery/Workspace branch recovery/02 Task journey", component: Journey, parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/PAP-24"], docs: { description: { component: "Interactive proposal in the production app shell, using the shared task bubbles, composer and repair component. Save/retry and branch lookup are simulated. Use master or choose release/next; a missing branch shows an inline error. No live task is modified." }, story: { inline: false } } }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/routine-detail-c.stories.tsx b/ui/storybook/stories/routines/routine-detail-c.stories.tsx similarity index 98% rename from ui/storybook/stories/routine-detail-c.stories.tsx rename to ui/storybook/stories/routines/routine-detail-c.stories.tsx index d85a6924b66..f536ef04caf 100644 --- a/ui/storybook/stories/routine-detail-c.stories.tsx +++ b/ui/storybook/stories/routines/routine-detail-c.stories.tsx @@ -33,7 +33,7 @@ import { ActivitySection, } from "@/components/routine-sections/operate-sections"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookAgents, storybookProjects } from "../fixtures/paperclipData"; +import { storybookAgents, storybookProjects } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const ROUTINE_ID = "routine-storybook"; @@ -440,7 +440,8 @@ function RoutineCShell({ } const meta: Meta = { - title: "Product/Routines · Detail (variation C)", + id: "product-routines-·-detail-variation-c", + title: "Automation and structured work/Routines, schedules, and triggers/Product/Routines · Detail (variation C)", component: RoutineCShell, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/routine-secrets.stories.tsx b/ui/storybook/stories/routines/routine-secrets.stories.tsx similarity index 97% rename from ui/storybook/stories/routine-secrets.stories.tsx rename to ui/storybook/stories/routines/routine-secrets.stories.tsx index 42bf771b2ba..8117b6ea4dc 100644 --- a/ui/storybook/stories/routine-secrets.stories.tsx +++ b/ui/storybook/stories/routines/routine-secrets.stories.tsx @@ -15,7 +15,7 @@ import { RoutineHistoryTab } from "@/components/RoutineHistoryTab"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useCompany } from "@/context/CompanyContext"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookCompanies, storybookSecrets } from "../fixtures/paperclipData"; +import { storybookCompanies, storybookSecrets } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; @@ -47,7 +47,8 @@ function StorybookRoutineFixtures({ } const meta: Meta = { - title: "Product/Routines · Secrets tab", + id: "product-routines-·-secrets-tab", + title: "Automation and structured work/Routines, schedules, and triggers/Product/Routines · Secrets tab", parameters: { layout: "fullscreen", a11y: { test: "off" }, diff --git a/ui/storybook/stories/routine-webhooks-ux.stories.tsx b/ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx similarity index 98% rename from ui/storybook/stories/routine-webhooks-ux.stories.tsx rename to ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx index a991e3598b7..093addf2e44 100644 --- a/ui/storybook/stories/routine-webhooks-ux.stories.tsx +++ b/ui/storybook/stories/routines/routine-webhooks-ux.stories.tsx @@ -1,6 +1,6 @@ -import { RoutineTriggerCard } from "../fixtures/routineTriggerCard"; -import { RoutineTriggerWizard, defaultTriggerDraft, type TriggerDraft } from "../fixtures/routineTriggerWizard"; -import { CopyField } from "../fixtures/routineWebhookFields"; +import { RoutineTriggerCard } from "../../fixtures/routineTriggerCard"; +import { RoutineTriggerWizard, defaultTriggerDraft, type TriggerDraft } from "../../fixtures/routineTriggerWizard"; +import { CopyField } from "../../fixtures/routineWebhookFields"; import { useCallback, useEffect, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; @@ -14,7 +14,7 @@ import { RoutineDetail } from "@/pages/RoutineDetail"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { Link, useNavigate, useParams } from "@/lib/router"; import { cn } from "@/lib/utils"; -import { WebhookReview } from "../fixtures/routineWebhooks"; +import { WebhookReview } from "../../fixtures/routineWebhooks"; // Deliberately Storybook-only: no production components or webhook APIs are changed. const endpoint = "https://acme.paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire"; @@ -191,7 +191,8 @@ function Prototype(props: Props) { return } />; } const meta = { - title: "Product/Routines/Webhooks UX preview", + id: "product-routines-webhooks-ux-preview", + title: "Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks UX preview", component: Prototype, parameters: { layout: "fullscreen" }, args: { stage: "setup", sender: "custom", page: "triggers", triggerKind: "choose", wizardStep: 0, scheduleSaved: false, checkResult: "waiting" }, diff --git a/ui/storybook/stories/routine-webhooks.stories.tsx b/ui/storybook/stories/routines/routine-webhooks.stories.tsx similarity index 95% rename from ui/storybook/stories/routine-webhooks.stories.tsx rename to ui/storybook/stories/routines/routine-webhooks.stories.tsx index 3d6da63bf4f..5769c0258d2 100644 --- a/ui/storybook/stories/routine-webhooks.stories.tsx +++ b/ui/storybook/stories/routines/routine-webhooks.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { WebhookReview } from "../fixtures/routineWebhooks"; +import { WebhookReview } from "../../fixtures/routineWebhooks"; const meta = { - title: "Product/Routines/Webhooks", + id: "product-routines-webhooks", + title: "Automation and structured work/Routines, schedules, and triggers/Product/Routines/Webhooks", component: WebhookReview, parameters: { layout: "fullscreen" }, args: { signingMode: "bearer", state: "configured" }, diff --git a/ui/storybook/stories/routines-list-groups.stories.tsx b/ui/storybook/stories/routines/routines-list-groups.stories.tsx similarity index 96% rename from ui/storybook/stories/routines-list-groups.stories.tsx rename to ui/storybook/stories/routines/routines-list-groups.stories.tsx index cf4da482678..03955004ac3 100644 --- a/ui/storybook/stories/routines-list-groups.stories.tsx +++ b/ui/storybook/stories/routines/routines-list-groups.stories.tsx @@ -91,7 +91,8 @@ function GroupedList() { } const meta: Meta = { - title: "Product/Routines · List (grouped cards)", + id: "product-routines-·-list-grouped-cards", + title: "Automation and structured work/Routines, schedules, and triggers/Product/Routines · List (grouped cards)", component: GroupedList, parameters: { layout: "fullscreen" }, }; diff --git a/ui/storybook/stories/agent-connection-reuse.stories.tsx b/ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx similarity index 95% rename from ui/storybook/stories/agent-connection-reuse.stories.tsx rename to ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx index d3f98a949d0..2fd484a1c5c 100644 --- a/ui/storybook/stories/agent-connection-reuse.stories.tsx +++ b/ui/storybook/stories/runs-adapters/agent-connection-reuse.stories.tsx @@ -9,7 +9,7 @@ import { resetOnboardingFixtureState, setOnboardingFixtureState, STORYBOOK_SANDBOX_ENVIRONMENT_ID, -} from "../fixtures/onboardingEnvironment"; +} from "../../fixtures/onboardingEnvironment"; function ConnectionStory({ adapterType = "claude_local", @@ -48,7 +48,8 @@ function ConnectionStory({ ); } const meta = { - title: "Onboarding/Saved connections", + id: "onboarding-saved-connections", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/Onboarding/Saved connections", component: ConnectionStory, parameters: { layout: "centered", diff --git a/ui/storybook/stories/ai-connection-usage.stories.tsx b/ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx similarity index 93% rename from ui/storybook/stories/ai-connection-usage.stories.tsx rename to ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx index d3b47a3a386..849cc67ac65 100644 --- a/ui/storybook/stories/ai-connection-usage.stories.tsx +++ b/ui/storybook/stories/runs-adapters/ai-connection-usage.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "storybook/test"; -import { AiConnectionUsageReview, USAGE_REVIEW_SCENARIOS } from "../prototypes/AiConnectionUsageReview"; +import { AiConnectionUsageReview, USAGE_REVIEW_SCENARIOS } from "../../prototypes/AiConnectionUsageReview"; const { codex, claude, grok, openRouter, unavailable } = USAGE_REVIEW_SCENARIOS; const meta = { - title: "AI Connections/Usage limits", + id: "ai-connections-usage-limits", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Usage limits", component: AiConnectionUsageReview, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/ai-connections.stories.tsx b/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx similarity index 97% rename from ui/storybook/stories/ai-connections.stories.tsx rename to ui/storybook/stories/runs-adapters/ai-connections.stories.tsx index 10f3419dcee..100856e459b 100644 --- a/ui/storybook/stories/ai-connections.stories.tsx +++ b/ui/storybook/stories/runs-adapters/ai-connections.stories.tsx @@ -1,17 +1,18 @@ -import { AiReviewFrame, aiReviewContext } from "../prototypes/AiReviewFrame"; -import { AiTaskConnectionReview } from "../prototypes/AiTaskConnectionReview"; +import { AiReviewFrame, aiReviewContext } from "../../prototypes/AiReviewFrame"; +import { AiTaskConnectionReview } from "../../prototypes/AiTaskConnectionReview"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { AiConnectionsReview } from "../prototypes/AiConnectionsReview"; -import { AiConnectorPages } from "../prototypes/AiConnectorPages"; +import { AiConnectionsReview } from "../../prototypes/AiConnectionsReview"; +import { AiConnectorPages } from "../../prototypes/AiConnectorPages"; import { AI_REVIEW_BINDING, AI_REVIEW_CONNECTIONS, AI_REVIEW_REQUIREMENT, -} from "../fixtures/aiConnections"; +} from "../../fixtures/aiConnections"; const meta = { - title: "AI Connections/Review", + id: "ai-connections-review", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Review", component: AiConnectionsReview, parameters: { layout: "fullscreen" }, decorators: [(Story, context) => ( diff --git a/ui/storybook/stories/connection-pool-connector.stories.tsx b/ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx similarity index 85% rename from ui/storybook/stories/connection-pool-connector.stories.tsx rename to ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx index d6dac52e8e9..dac2a11391b 100644 --- a/ui/storybook/stories/connection-pool-connector.stories.tsx +++ b/ui/storybook/stories/runs-adapters/connection-pool-connector.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { ConnectionPoolConnectorHost } from "../prototypes/ConnectionPoolConnectorHost"; -const meta = { title: "Connectors/Pool host", component: ConnectionPoolConnectorHost, parameters: { layout: "fullscreen" } } satisfies Meta; +import { ConnectionPoolConnectorHost } from "../../prototypes/ConnectionPoolConnectorHost"; +const meta = { id: "connectors-pool-host", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/Connectors/Pool host", component: ConnectionPoolConnectorHost, parameters: { layout: "fullscreen" } } satisfies Meta; export default meta; type Story = StoryObj; export const Catalog: Story = {}; diff --git a/ui/storybook/stories/connection-pools.stories.tsx b/ui/storybook/stories/runs-adapters/connection-pools.stories.tsx similarity index 92% rename from ui/storybook/stories/connection-pools.stories.tsx rename to ui/storybook/stories/runs-adapters/connection-pools.stories.tsx index 0f480d7156c..54ee9f59d36 100644 --- a/ui/storybook/stories/connection-pools.stories.tsx +++ b/ui/storybook/stories/runs-adapters/connection-pools.stories.tsx @@ -10,7 +10,7 @@ import { ComposerRunSettingsPicker } from "@/components/task-chat/ComposerRunSet import type { ComposerRunSettings } from "@/components/task-chat/composer-run-settings"; import { RunRetryDetails } from "@/components/RunRetryDetails"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookAgents, storybookIssues } from "../fixtures/paperclipData"; +import { storybookAgents, storybookIssues } from "../../fixtures/paperclipData"; const companyId = "company-storybook", agentId = "agent-codex"; const poolId = "10000000-0000-4000-8000-000000000001"; @@ -74,7 +74,8 @@ function PoolSurfaces({ scenario = "overview" }: { scenario?: Scenario }) { {(overview || ["run-codex", "run-claude", "fallback", "retry"].includes(scenario)) &&

Run account and runtime

} ; } -const meta = { title: "AI Connections/Connection pools", component: PoolSurfaces, parameters: { layout: "fullscreen", docs: { description: { component: "All user-facing Core additions for the private connection-pool plugin. These stories mount the production selector, composer, quota banners, and run details/retry metadata. Routing requires manual operator configuration; there is no settings toggle. Accounts are fixtures; no live provider calls or instance mutations occur." } } }, render: args => } satisfies Meta; +const meta = { id: "ai-connections-connection-pools", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Connection pools", component: PoolSurfaces, parameters: { layout: "fullscreen", docs: { description: { component: "All user-facing Core additions for the private connection-pool plugin. These stories mount the production selector, composer, quota banners, and run details/retry metadata. Routing requires manual operator configuration; there is no settings toggle. Accounts are fixtures; no live provider calls or instance mutations occur." } } }, render: args => } satisfies Meta; export default meta; type Story = StoryObj; export const AllCoreSurfaces: Story = { args: { scenario: "overview" } }; diff --git a/ui/storybook/stories/dot-runner-connection.stories.tsx b/ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx similarity index 94% rename from ui/storybook/stories/dot-runner-connection.stories.tsx rename to ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx index fd00dd211b6..18b0cbb3ac5 100644 --- a/ui/storybook/stories/dot-runner-connection.stories.tsx +++ b/ui/storybook/stories/runs-adapters/dot-runner-connection.stories.tsx @@ -33,7 +33,8 @@ function ConnectionForm() {
; } const meta = { - title: "Assistant connections/Dot Runner", component: ConnectionForm, + id: "assistant-connections-dot-runner", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/Assistant connections/Dot Runner", component: ConnectionForm, parameters: { layout: "padded", docs: { description: { component: "Production Dot pairing component. API responses, pairing code and account state are synthetic. This preview does not qualify a real Dot account." } } }, beforeEach: ({ parameters }) => installFixture(Boolean(parameters.connected)), } satisfies Meta; diff --git a/ui/storybook/stories/local-ai-subscription.stories.tsx b/ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx similarity index 92% rename from ui/storybook/stories/local-ai-subscription.stories.tsx rename to ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx index b5c8ddb8dda..57a7cf82cc9 100644 --- a/ui/storybook/stories/local-ai-subscription.stories.tsx +++ b/ui/storybook/stories/runs-adapters/local-ai-subscription.stories.tsx @@ -3,7 +3,8 @@ import { expect, userEvent, waitFor, within } from "storybook/test"; import { LocalProviderLoginInstructions } from "@/components/AdapterLoginChrome"; const meta = { - title: "AI Connections/Provider routing/07 Subscription sign-in", + id: "ai-connections-provider-routing-07-subscription-sign-in", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/07 Subscription sign-in", component: LocalProviderLoginInstructions, parameters: { layout: "centered" }, } satisfies Meta; diff --git a/ui/storybook/stories/provider-routing-00-overview.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx similarity index 52% rename from ui/storybook/stories/provider-routing-00-overview.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx index 868505913ab..32cbb80c225 100644 --- a/ui/storybook/stories/provider-routing-00-overview.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-00-overview.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ReviewIndex } from "../prototypes/provider-routing/ReviewIndex"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; -import { reviewLifecycle } from "../prototypes/provider-routing/story-support"; +import { ReviewIndex } from "../../prototypes/provider-routing/ReviewIndex"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; +import { reviewLifecycle } from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/00 Overview", + id: "ai-connections-provider-routing-00-overview", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/00 Overview", component: ReviewIndex, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-01-onboarding.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx similarity index 89% rename from ui/storybook/stories/provider-routing-01-onboarding.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx index 1fa29f8a343..ee0cf983ab7 100644 --- a/ui/storybook/stories/provider-routing-01-onboarding.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-01-onboarding.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { Onboarding } from "../prototypes/provider-routing/AgentSetup"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; -import { reviewLifecycle } from "../prototypes/provider-routing/story-support"; +import { Onboarding } from "../../prototypes/provider-routing/AgentSetup"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; +import { reviewLifecycle } from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/01 Onboarding", + id: "ai-connections-provider-routing-01-onboarding", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/01 Onboarding", component: Onboarding, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-02-connect.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx similarity index 88% rename from ui/storybook/stories/provider-routing-02-connect.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx index 8d0f42ca24c..04294737078 100644 --- a/ui/storybook/stories/provider-routing-02-connect.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-02-connect.stories.tsx @@ -1,14 +1,15 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { ProviderCatalogReview } from "../prototypes/provider-routing/ConnectionCatalog"; -import { ConnectionSetup } from "../prototypes/provider-routing/ConnectionSetup"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; +import { ProviderCatalogReview } from "../../prototypes/provider-routing/ConnectionCatalog"; +import { ConnectionSetup } from "../../prototypes/provider-routing/ConnectionSetup"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; import { choose, reviewLifecycle, -} from "../prototypes/provider-routing/story-support"; +} from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/02 Connect", + id: "ai-connections-provider-routing-02-connect", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/02 Connect", component: ConnectionSetup, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-03-agent.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx similarity index 96% rename from ui/storybook/stories/provider-routing-03-agent.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx index 2b45c5ddfd8..dd7b66339d1 100644 --- a/ui/storybook/stories/provider-routing-03-agent.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-03-agent.stories.tsx @@ -1,13 +1,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "storybook/test"; -import { AgentSetup } from "../prototypes/provider-routing/AgentSetup"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; +import { AgentSetup } from "../../prototypes/provider-routing/AgentSetup"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; import { choose, reviewLifecycle, -} from "../prototypes/provider-routing/story-support"; +} from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/03 Agent", + id: "ai-connections-provider-routing-03-agent", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/03 Agent", component: AgentSetup, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-04-manage.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx similarity index 89% rename from ui/storybook/stories/provider-routing-04-manage.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx index 7e5299a7456..3b40f1b84ea 100644 --- a/ui/storybook/stories/provider-routing-04-manage.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-04-manage.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { Management } from "../prototypes/provider-routing/Management"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; -import { reviewLifecycle } from "../prototypes/provider-routing/story-support"; +import { Management } from "../../prototypes/provider-routing/Management"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; +import { reviewLifecycle } from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/04 Manage", + id: "ai-connections-provider-routing-04-manage", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/04 Manage", component: Management, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-05-recovery.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx similarity index 87% rename from ui/storybook/stories/provider-routing-05-recovery.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx index 222c79ec859..e1e9b33a8f1 100644 --- a/ui/storybook/stories/provider-routing-05-recovery.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-05-recovery.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { Recovery } from "../prototypes/provider-routing/Recovery"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; -import { reviewLifecycle } from "../prototypes/provider-routing/story-support"; +import { Recovery } from "../../prototypes/provider-routing/Recovery"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; +import { reviewLifecycle } from "../../prototypes/provider-routing/story-support"; const meta = { - title: "AI Connections/Provider routing/05 Recovery", + id: "ai-connections-provider-routing-05-recovery", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/05 Recovery", component: Recovery, parameters: { layout: "fullscreen" }, ...reviewLifecycle, diff --git a/ui/storybook/stories/provider-routing-06-production.stories.tsx b/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx similarity index 97% rename from ui/storybook/stories/provider-routing-06-production.stories.tsx rename to ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx index 774e3481073..2454daf4179 100644 --- a/ui/storybook/stories/provider-routing-06-production.stories.tsx +++ b/ui/storybook/stories/runs-adapters/provider-routing-06-production.stories.tsx @@ -15,8 +15,8 @@ import type { AiManagedConnectionSummary, } from "@paperclipai/shared"; import { expect, userEvent, waitFor, within } from "storybook/test"; -import { ProviderCatalogReview } from "../prototypes/provider-routing/ConnectionCatalog"; -import { ReviewFrame } from "../prototypes/provider-routing/shared"; +import { ProviderCatalogReview } from "../../prototypes/provider-routing/ConnectionCatalog"; +import { ReviewFrame } from "../../prototypes/provider-routing/shared"; const companyId = "00000000-0000-4000-8000-000000000001"; const accounts: AiManagedConnectionSummary[] = [ @@ -161,7 +161,8 @@ function AgentConnectionModes() { ; } const meta = { - title: "AI Connections/Provider routing/06 Production components", + id: "ai-connections-provider-routing-06-production-components", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/AI Connections/Provider routing/06 Production components", parameters: { layout: "fullscreen" }, decorators: [ (Story) => ( diff --git a/ui/storybook/stories/session-goals.stories.tsx b/ui/storybook/stories/runs-adapters/session-goals.stories.tsx similarity index 97% rename from ui/storybook/stories/session-goals.stories.tsx rename to ui/storybook/stories/runs-adapters/session-goals.stories.tsx index b9634405186..6658a1ae754 100644 --- a/ui/storybook/stories/session-goals.stories.tsx +++ b/ui/storybook/stories/runs-adapters/session-goals.stories.tsx @@ -159,7 +159,8 @@ function SessionGoalStates() { } const meta = { - title: "Composer/Session goals", + id: "composer-session-goals", + title: "Agents and reusable capabilities/Runs, harnesses, and model accounts/Composer/Session goals", component: SessionGoalStates, parameters: { docs: { diff --git a/ui/storybook/stories/credential-access/01-notice.stories.tsx b/ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx similarity index 91% rename from ui/storybook/stories/credential-access/01-notice.stories.tsx rename to ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx index 91ea2eb8a6d..3e541190fae 100644 --- a/ui/storybook/stories/credential-access/01-notice.stories.tsx +++ b/ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker"; const meta = { - title: "Task credential access/01 Notice", + id: "task-credential-access-01-notice", + title: "Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice", component: TaskChatMarker, parameters: { layout: "centered" }, decorators: [Story =>
], diff --git a/ui/storybook/stories/credential-access/02-task-page.stories.tsx b/ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx similarity index 82% rename from ui/storybook/stories/credential-access/02-task-page.stories.tsx rename to ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx index e60aa8f9773..5914d100ef0 100644 --- a/ui/storybook/stories/credential-access/02-task-page.stories.tsx +++ b/ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { CredentialTaskPage } from "./CredentialTaskPage"; +import { CredentialTaskPage } from "../../../support/credential-access/CredentialTaskPage"; const meta = { - title: "Task credential access/02 Task page", + id: "task-credential-access-02-task-page", + title: "Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page", component: CredentialTaskPage, parameters: { layout: "fullscreen", waitForViewport: true, docs: { description: { component: "Production task detail and app shell. The latest credential failure stays expanded in the conversation and names the selected credential. The settings link uses the existing agent runtime route. Example data is local to Storybook." } }, diff --git a/ui/storybook/stories/environment-variables-editor.stories.tsx b/ui/storybook/stories/secrets/environment-variables-editor.stories.tsx similarity index 98% rename from ui/storybook/stories/environment-variables-editor.stories.tsx rename to ui/storybook/stories/secrets/environment-variables-editor.stories.tsx index b84afbd1801..e0aaa83dc11 100644 --- a/ui/storybook/stories/environment-variables-editor.stories.tsx +++ b/ui/storybook/stories/secrets/environment-variables-editor.stories.tsx @@ -91,7 +91,8 @@ function Editor({ } const meta: Meta = { - title: "Product/Environment Variables Editor", + id: "product-environment-variables-editor", + title: "Apps, channels, and extensions/Secrets and proposals/Product/Environment Variables Editor", parameters: { layout: "fullscreen", a11y: { test: "off" } }, }; export default meta; diff --git a/ui/storybook/stories/secrets.stories.tsx b/ui/storybook/stories/secrets/secrets.stories.tsx similarity index 98% rename from ui/storybook/stories/secrets.stories.tsx rename to ui/storybook/stories/secrets/secrets.stories.tsx index 27fb6592397..fb039058f1f 100644 --- a/ui/storybook/stories/secrets.stories.tsx +++ b/ui/storybook/stories/secrets/secrets.stories.tsx @@ -15,7 +15,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge"; import { useCompany } from "@/context/CompanyContext"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookCompanies, storybookSecrets } from "../fixtures/paperclipData"; +import { storybookCompanies, storybookSecrets } from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; @@ -118,7 +118,8 @@ function StorybookSecretsFixtures({ children }: { children: ReactNode }) { } const meta: Meta = { - title: "Product/Secrets", + id: "product-secrets", + title: "Apps, channels, and extensions/Secrets and proposals/Product/Secrets", parameters: { layout: "fullscreen", a11y: { diff --git a/ui/storybook/stories/user-secrets.stories.tsx b/ui/storybook/stories/secrets/user-secrets.stories.tsx similarity index 98% rename from ui/storybook/stories/user-secrets.stories.tsx rename to ui/storybook/stories/secrets/user-secrets.stories.tsx index 2244ba4a20e..43544b079e0 100644 --- a/ui/storybook/stories/user-secrets.stories.tsx +++ b/ui/storybook/stories/secrets/user-secrets.stories.tsx @@ -140,7 +140,8 @@ function Section({ title, children }: { title: string; children: ReactNode }) { } const meta: Meta = { - title: "Product/User secrets", + id: "product-user-secrets", + title: "Apps, channels, and extensions/Secrets and proposals/Product/User secrets", parameters: { layout: "fullscreen", a11y: { test: "off" } }, }; export default meta; diff --git a/ui/storybook/stories/agent-skill-row.stories.tsx b/ui/storybook/stories/skills/agent-skill-row.stories.tsx similarity index 95% rename from ui/storybook/stories/agent-skill-row.stories.tsx rename to ui/storybook/stories/skills/agent-skill-row.stories.tsx index fef898a117d..c90f4e411f2 100644 --- a/ui/storybook/stories/agent-skill-row.stories.tsx +++ b/ui/storybook/stories/skills/agent-skill-row.stories.tsx @@ -42,7 +42,8 @@ const READONLY: AgentSkillRowData = { }; const meta: Meta = { - title: "Agent Skills/AgentSkillRow", + id: "agent-skills-agentskillrow", + title: "Agents and reusable capabilities/Skills and Skill Studio/Agent Skills/AgentSkillRow", component: AgentSkillRow, parameters: { layout: "padded" }, decorators: [ diff --git a/ui/storybook/stories/agent-skills-edge.stories.tsx b/ui/storybook/stories/skills/agent-skills-edge.stories.tsx similarity index 98% rename from ui/storybook/stories/agent-skills-edge.stories.tsx rename to ui/storybook/stories/skills/agent-skills-edge.stories.tsx index ff04589393c..939846b4fdd 100644 --- a/ui/storybook/stories/agent-skills-edge.stories.tsx +++ b/ui/storybook/stories/skills/agent-skills-edge.stories.tsx @@ -192,7 +192,8 @@ function EdgeState({ agent, snapshot }: { agent: Agent; snapshot: AgentSkillSnap } const meta: Meta = { - title: "QA / Agent skills edge states", + id: "qa-agent-skills-edge-states", + title: "Agents and reusable capabilities/Skills and Skill Studio/QA / Agent skills edge states", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/agents-using-skill.stories.tsx b/ui/storybook/stories/skills/agents-using-skill.stories.tsx similarity index 97% rename from ui/storybook/stories/agents-using-skill.stories.tsx rename to ui/storybook/stories/skills/agents-using-skill.stories.tsx index f024c2283af..d813286384f 100644 --- a/ui/storybook/stories/agents-using-skill.stories.tsx +++ b/ui/storybook/stories/skills/agents-using-skill.stories.tsx @@ -119,7 +119,8 @@ function Seed({ skill, children }: { skill: CompanySkillDetail; children: ReactN } const meta: Meta = { - title: "Skill Studio/AgentsUsingSkill", + id: "skill-studio-agentsusingskill", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/AgentsUsingSkill", parameters: { layout: "centered" }, }; export default meta; diff --git a/ui/storybook/stories/fork-skill-flow.stories.tsx b/ui/storybook/stories/skills/fork-skill-flow.stories.tsx similarity index 98% rename from ui/storybook/stories/fork-skill-flow.stories.tsx rename to ui/storybook/stories/skills/fork-skill-flow.stories.tsx index 1c250e0bd8c..c635419c204 100644 --- a/ui/storybook/stories/fork-skill-flow.stories.tsx +++ b/ui/storybook/stories/skills/fork-skill-flow.stories.tsx @@ -152,7 +152,8 @@ function Seed({ } const meta: Meta = { - title: "Skill Studio/EditACopy", + id: "skill-studio-editacopy", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skill Studio/EditACopy", parameters: { layout: "centered" }, }; export default meta; diff --git a/ui/storybook/stories/frontmatter-panel.stories.tsx b/ui/storybook/stories/skills/frontmatter-panel.stories.tsx similarity index 97% rename from ui/storybook/stories/frontmatter-panel.stories.tsx rename to ui/storybook/stories/skills/frontmatter-panel.stories.tsx index f4f510bc26d..a09ae7bb3eb 100644 --- a/ui/storybook/stories/frontmatter-panel.stories.tsx +++ b/ui/storybook/stories/skills/frontmatter-panel.stories.tsx @@ -66,7 +66,8 @@ metadata: version: 2`; const meta: Meta = { - title: "Product/Skill Studio · FrontmatterPanel", + id: "product-skill-studio-·-frontmatterpanel", + title: "Agents and reusable capabilities/Skills and Skill Studio/Product/Skill Studio · FrontmatterPanel", component: Harness, parameters: { layout: "padded" }, }; diff --git a/ui/storybook/stories/github-skills-journey.stories.tsx b/ui/storybook/stories/skills/github-skills-journey.stories.tsx similarity index 97% rename from ui/storybook/stories/github-skills-journey.stories.tsx rename to ui/storybook/stories/skills/github-skills-journey.stories.tsx index fba52de7597..aad4fb527d5 100644 --- a/ui/storybook/stories/github-skills-journey.stories.tsx +++ b/ui/storybook/stories/skills/github-skills-journey.stories.tsx @@ -11,7 +11,7 @@ import { SkillStudio } from "@/pages/SkillStudio"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { Link, Route, Routes, useNavigate } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; -import { COMPANY_ID, COMMIT, installFixtures, repositoryCandidates, repositoryFixture, SOURCE_ID } from "../fixtures/githubSkillSources"; +import { COMPANY_ID, COMMIT, installFixtures, repositoryCandidates, repositoryFixture, SOURCE_ID } from "../../fixtures/githubSkillSources"; type Step = "start" | "repository" | "selection" | "imported" | "library" | "detail" | "agents" | "assigned" | "refresh" | "new-skills" | "scanning" | "saving"; const routes: Record = { @@ -89,7 +89,8 @@ export function GitHubSkillsJourney({ step = "start", packageScenario }: { step? } const meta = { - title: "Skills/GitHub sync journey", + id: "skills-github-sync-journey", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub sync journey", component: GitHubSkillsJourney, tags: ["!autodocs"], parameters: { diff --git a/ui/storybook/stories/github-synced-skills.stories.tsx b/ui/storybook/stories/skills/github-synced-skills.stories.tsx similarity index 97% rename from ui/storybook/stories/github-synced-skills.stories.tsx rename to ui/storybook/stories/skills/github-synced-skills.stories.tsx index f73dcd99fa1..5fbf0aa45ad 100644 --- a/ui/storybook/stories/github-synced-skills.stories.tsx +++ b/ui/storybook/stories/skills/github-synced-skills.stories.tsx @@ -1,7 +1,7 @@ import { userEvent, within } from "storybook/test"; import { useEffect, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { COMPANY_ID, SOURCE_ID, installFixtures, type RepositoryScenario } from "../fixtures/githubSkillSources"; +import { COMPANY_ID, SOURCE_ID, installFixtures, type RepositoryScenario } from "../../fixtures/githubSkillSources"; import { Link, Route, Routes, useNavigate } from "@/lib/router"; import { SkillSources } from "@/pages/SkillSources"; @@ -33,7 +33,8 @@ function SourcesStory({ view = "sources", empty = false, needsConnection = false } const meta = { - title: "Skills/GitHub synced skills", + id: "skills-github-synced-skills", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills/GitHub synced skills", component: SourcesStory, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/repository-skill-packages/repository-skill-packages.stories.tsx b/ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx similarity index 93% rename from ui/storybook/stories/repository-skill-packages/repository-skill-packages.stories.tsx rename to ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx index 3e8cb341a14..baf30f5e971 100644 --- a/ui/storybook/stories/repository-skill-packages/repository-skill-packages.stories.tsx +++ b/ui/storybook/stories/skills/repository-skill-packages/repository-skill-packages.stories.tsx @@ -3,7 +3,8 @@ import { userEvent, within } from 'storybook/test'; import { GitHubSkillsJourney } from '../github-skills-journey.stories'; const meta = { - title: 'Skills/Repository packages', component: GitHubSkillsJourney, + id: "skills-repository-packages", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills/Repository packages", component: GitHubSkillsJourney, tags: ['!autodocs'], parameters: { layout: 'fullscreen', docs: { description: { component: 'Production Skills UI in the app shell with repository fixtures. Repository files stay together. Select which skills appear in your library. No GitHub access or runtime execution occurs in this preview.' } } }, args: { packageScenario: 'ready' }, diff --git a/ui/storybook/stories/skill-import-progress.stories.tsx b/ui/storybook/stories/skills/skill-import-progress.stories.tsx similarity index 90% rename from ui/storybook/stories/skill-import-progress.stories.tsx rename to ui/storybook/stories/skills/skill-import-progress.stories.tsx index fc1d8dda02b..77a177d657d 100644 --- a/ui/storybook/stories/skill-import-progress.stories.tsx +++ b/ui/storybook/stories/skills/skill-import-progress.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { SkillImportProgress } from '@/pages/skills/SkillImportProgress'; -import { candidates } from '../fixtures/githubSkillSources'; +import { candidates } from '../../fixtures/githubSkillSources'; const meta = { - title: 'Skills/Import progress', + id: "skills-import-progress", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills/Import progress", component: SkillImportProgress, args: { repository: 'acme/team-skills' }, decorators: [Story =>
], diff --git a/ui/storybook/stories/skill-policy-surfaces.stories.tsx b/ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx similarity index 94% rename from ui/storybook/stories/skill-policy-surfaces.stories.tsx rename to ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx index 1b3356d2c1d..11b17e2daee 100644 --- a/ui/storybook/stories/skill-policy-surfaces.stories.tsx +++ b/ui/storybook/stories/skills/skill-policy-surfaces.stories.tsx @@ -34,7 +34,8 @@ function Frame({ label, width, children }: { label: string; width: number; child } const meta = { - title: "Product/Skills/Policy surfaces", + id: "product-skills-policy-surfaces", + title: "Agents and reusable capabilities/Skills and Skill Studio/Product/Skills/Policy surfaces", component: SkillPolicyDenialNotice, args: { denial: policyDenial() }, parameters: { diff --git a/ui/storybook/stories/skills-store-detail.stories.tsx b/ui/storybook/stories/skills/skills-store-detail.stories.tsx similarity index 98% rename from ui/storybook/stories/skills-store-detail.stories.tsx rename to ui/storybook/stories/skills/skills-store-detail.stories.tsx index 7d91ba56b09..1661541bb91 100644 --- a/ui/storybook/stories/skills-store-detail.stories.tsx +++ b/ui/storybook/stories/skills/skills-store-detail.stories.tsx @@ -200,7 +200,8 @@ function SkillDetailHarness({ initialTab = "overview" as DetailTab }: { initialT } const meta: Meta = { - title: "Skills Store/Skill detail", + id: "skills-store-skill-detail", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Skill detail", component: SkillDetailHarness, parameters: { layout: "fullscreen" }, }; diff --git a/ui/storybook/stories/skills-store-discovery.stories.tsx b/ui/storybook/stories/skills/skills-store-discovery.stories.tsx similarity index 98% rename from ui/storybook/stories/skills-store-discovery.stories.tsx rename to ui/storybook/stories/skills/skills-store-discovery.stories.tsx index 2bcb7c2c988..3ffb36ab6db 100644 --- a/ui/storybook/stories/skills-store-discovery.stories.tsx +++ b/ui/storybook/stories/skills/skills-store-discovery.stories.tsx @@ -306,7 +306,8 @@ function DiscoveryGridHarness({ } const meta: Meta = { - title: "Skills Store/Discovery grid", + id: "skills-store-discovery-grid", + title: "Agents and reusable capabilities/Skills and Skill Studio/Skills Store/Discovery grid", component: DiscoveryGridHarness, parameters: { layout: "fullscreen" }, }; diff --git a/ui/storybook/stories/composer-queued-messages.stories.tsx b/ui/storybook/stories/steering/composer-queued-messages.stories.tsx similarity index 96% rename from ui/storybook/stories/composer-queued-messages.stories.tsx rename to ui/storybook/stories/steering/composer-queued-messages.stories.tsx index b2dfe5a561b..d23c3ba4c9c 100644 --- a/ui/storybook/stories/composer-queued-messages.stories.tsx +++ b/ui/storybook/stories/steering/composer-queued-messages.stories.tsx @@ -4,7 +4,7 @@ import { expect, userEvent, within } from "storybook/test"; import type { IssueQueuedCommentEntry, IssueQueuedCommentQueue } from "@paperclipai/shared"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages"; -import { composerAgentAppearance, composerAgents } from "../prototypes/composer-model-picker/fixtures"; +import { composerAgentAppearance, composerAgents } from "../../prototypes/composer-model-picker/fixtures"; const agentMap = new Map(composerAgents.map((agent) => [agent.id, { id: agent.id, @@ -127,7 +127,8 @@ function QueuedComposer({ protocol, steeringDisposition, initialEdit, mobile }: } const meta = { - title: "Composer/Steering and queued messages", + id: "composer-steering-and-queued-messages", + title: "Tasks and conversations/Steering and queued messages/Composer/Steering and queued messages", component: QueuedComposer, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/interrupt-handoff.stories.tsx b/ui/storybook/stories/steering/interrupt-handoff.stories.tsx similarity index 98% rename from ui/storybook/stories/interrupt-handoff.stories.tsx rename to ui/storybook/stories/steering/interrupt-handoff.stories.tsx index 9c1f2f0c9dc..dd15023b494 100644 --- a/ui/storybook/stories/interrupt-handoff.stories.tsx +++ b/ui/storybook/stories/steering/interrupt-handoff.stories.tsx @@ -101,7 +101,8 @@ function ActivityCard({ } const meta: Meta = { - title: "Surfaces/Interrupt Handoff", + id: "surfaces-interrupt-handoff", + title: "Tasks and conversations/Steering and queued messages/Surfaces/Interrupt Handoff", }; export default meta; diff --git a/ui/storybook/stories/paused-composer.stories.tsx b/ui/storybook/stories/steering/paused-composer.stories.tsx similarity index 85% rename from ui/storybook/stories/paused-composer.stories.tsx rename to ui/storybook/stories/steering/paused-composer.stories.tsx index 83670ad3fa4..e5582e64078 100644 --- a/ui/storybook/stories/paused-composer.stories.tsx +++ b/ui/storybook/stories/steering/paused-composer.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PausedComposerPreview } from "../prototypes/PausedTaskComposer"; +import { PausedComposerPreview } from "../../prototypes/PausedTaskComposer"; const meta = { - title: "Composer/Paused task takeover", + id: "composer-paused-task-takeover", + title: "Tasks and conversations/Steering and queued messages/Composer/Paused task takeover", component: PausedComposerPreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/task-execution-controls.stories.tsx b/ui/storybook/stories/steering/task-execution-controls.stories.tsx similarity index 99% rename from ui/storybook/stories/task-execution-controls.stories.tsx rename to ui/storybook/stories/steering/task-execution-controls.stories.tsx index 27d72d43405..735fff914a2 100644 --- a/ui/storybook/stories/task-execution-controls.stories.tsx +++ b/ui/storybook/stories/steering/task-execution-controls.stories.tsx @@ -318,7 +318,8 @@ function TaskExecutionExample({ } const meta = { - title: "Composer/Execution controls", + id: "composer-execution-controls", + title: "Tasks and conversations/Steering and queued messages/Composer/Execution controls", component: TaskExecutionExample, parameters: { layout: "padded" }, } satisfies Meta; diff --git a/ui/storybook/stories/issue-review-panel.stories.tsx b/ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx similarity index 95% rename from ui/storybook/stories/issue-review-panel.stories.tsx rename to ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx index 7aada79bd05..22638d9abf0 100644 --- a/ui/storybook/stories/issue-review-panel.stories.tsx +++ b/ui/storybook/stories/task-coordination/issue-review-panel.stories.tsx @@ -83,7 +83,8 @@ const stalledReviewRow: AttentionItem = { }; const meta = { - title: "Product/Decisions/Stalled review actions", + id: "product-decisions-stalled-review-actions", + title: "Tasks and conversations/Delegation, dependencies, and signoff/Product/Decisions/Stalled review actions", component: AttentionQueueRow, args: { item: stalledReviewRow, diff --git a/ui/storybook/stories/monitor-surfaces.stories.tsx b/ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx similarity index 98% rename from ui/storybook/stories/monitor-surfaces.stories.tsx rename to ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx index 92bc8b219d1..09357f19d7d 100644 --- a/ui/storybook/stories/monitor-surfaces.stories.tsx +++ b/ui/storybook/stories/task-coordination/monitor-surfaces.stories.tsx @@ -6,7 +6,7 @@ import { storybookExecutionWorkspaces, storybookIssueDocuments, storybookIssues, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const issueDocumentSummaries = storybookIssueDocuments.map(({ body: _body, ...summary }) => summary); @@ -310,7 +310,8 @@ function MonitorSurfaceStories() { } const meta = { - title: "Product/Issue Monitor surfaces", + id: "product-issue-monitor-surfaces", + title: "Tasks and conversations/Delegation, dependencies, and signoff/Product/Issue Monitor surfaces", component: MonitorSurfaceStories, parameters: { docs: { diff --git a/ui/storybook/stories/originating-tasks.stories.tsx b/ui/storybook/stories/task-coordination/originating-tasks.stories.tsx similarity index 91% rename from ui/storybook/stories/originating-tasks.stories.tsx rename to ui/storybook/stories/task-coordination/originating-tasks.stories.tsx index 4d4ee451945..a7932689b58 100644 --- a/ui/storybook/stories/originating-tasks.stories.tsx +++ b/ui/storybook/stories/task-coordination/originating-tasks.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { PluginLauncherProvider } from "@/plugins/launchers"; -import { OriginatingTasksReview, SeedData } from "../prototypes/originating-tasks/OriginatingTasks"; +import { OriginatingTasksReview, SeedData } from "../../prototypes/originating-tasks/OriginatingTasks"; const meta = { - title: "UX Labs/Tasks Created From a Task", + id: "ux-labs-tasks-created-from-a-task", + title: "Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Tasks Created From a Task", component: OriginatingTasksReview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/sub-issues-workflow.stories.tsx b/ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx similarity index 97% rename from ui/storybook/stories/sub-issues-workflow.stories.tsx rename to ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx index 4f4132d440c..c35e4874b8b 100644 --- a/ui/storybook/stories/sub-issues-workflow.stories.tsx +++ b/ui/storybook/stories/task-coordination/sub-issues-workflow.stories.tsx @@ -11,7 +11,7 @@ import { storybookCompanies, storybookIssueLabels, storybookProjects, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const companyId = "company-storybook"; const parentId = "issue-pap-1953"; @@ -254,7 +254,8 @@ function SubIssuesWorkflowPanel() { } const meta = { - title: "UX Labs/Sub-issues Workflow Checklist", + id: "ux-labs-sub-issues-workflow-checklist", + title: "Tasks and conversations/Delegation, dependencies, and signoff/UX Labs/Sub-issues Workflow Checklist", component: SubIssuesWorkflowPanel, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/task-watchdog-surfaces.stories.tsx b/ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx similarity index 95% rename from ui/storybook/stories/task-watchdog-surfaces.stories.tsx rename to ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx index 0ca142fa57b..3530f65f1db 100644 --- a/ui/storybook/stories/task-watchdog-surfaces.stories.tsx +++ b/ui/storybook/stories/task-coordination/task-watchdog-surfaces.stories.tsx @@ -5,7 +5,7 @@ import { storybookExecutionWorkspaces, storybookIssueDocuments, storybookIssues, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const issueDocumentSummaries = storybookIssueDocuments.map(({ body: _body, ...summary }) => summary); @@ -102,7 +102,8 @@ function WatchdogSurfaceStories() { } const meta = { - title: "Product/Task Watchdog surfaces", + id: "product-task-watchdog-surfaces", + title: "Tasks and conversations/Delegation, dependencies, and signoff/Product/Task Watchdog surfaces", component: WatchdogSurfaceStories, parameters: { docs: { diff --git a/ui/storybook/stories/assigned-backlog-safeguards.stories.tsx b/ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx similarity index 97% rename from ui/storybook/stories/assigned-backlog-safeguards.stories.tsx rename to ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx index 47f40026620..78680e64901 100644 --- a/ui/storybook/stories/assigned-backlog-safeguards.stories.tsx +++ b/ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx @@ -5,7 +5,7 @@ import type { IssueRelationIssueSummary } from "@paperclipai/shared"; import { IssueAssignedBacklogNotice } from "@/components/IssueAssignedBacklogNotice"; import { IssueBlockedNotice } from "@/components/IssueBlockedNotice"; import { IssueRow } from "@/components/IssueRow"; -import { storybookAgents, createIssue } from "../fixtures/paperclipData"; +import { storybookAgents, createIssue } from "../../fixtures/paperclipData"; const codexAgent = storybookAgents.find((agent) => agent.id === "agent-codex") ?? storybookAgents[0]!; const qaAgent = storybookAgents.find((agent) => agent.id === "agent-qa") ?? storybookAgents[0]!; @@ -205,7 +205,8 @@ function AllStates() { } const meta = { - title: "Paperclip/Assigned Backlog Safeguards", + id: "paperclip-assigned-backlog-safeguards", + title: "Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards", component: AllStates, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/chat-comments.stories.tsx b/ui/storybook/stories/tasks/chat-comments.stories.tsx similarity index 98% rename from ui/storybook/stories/chat-comments.stories.tsx rename to ui/storybook/stories/tasks/chat-comments.stories.tsx index 8762c4d1f1b..ba2bbd8d0cc 100644 --- a/ui/storybook/stories/chat-comments.stories.tsx +++ b/ui/storybook/stories/tasks/chat-comments.stories.tsx @@ -16,7 +16,7 @@ import type { IssueChatTranscriptEntry, } from "@/lib/issue-chat-messages"; import type { IssueTimelineEvent } from "@/lib/issue-timeline-events"; -import { storybookAgentMap, storybookAgents } from "../fixtures/paperclipData"; +import { storybookAgentMap, storybookAgents } from "../../fixtures/paperclipData"; const companyId = "company-storybook"; const projectId = "project-board-ui"; @@ -361,7 +361,7 @@ const liveRunTranscript: TranscriptEntry[] = [ name: "apply_patch", toolUseId: "tool-live-patch", input: { - file: "ui/storybook/stories/chat-comments.stories.tsx", + file: "ui/storybook/stories/tasks/chat-comments.stories.tsx", action: "add fixtures", }, }, @@ -557,7 +557,7 @@ const issueTranscriptsByRunId = new Map [agent.id, { id: agent.id, @@ -108,7 +108,8 @@ function ComposerAddStory({ initialMode, goalAvailable, mobile, mobileContext, f } const meta = { - title: "Composer/Add menu", + id: "composer-add-menu", + title: "Tasks and conversations/Task creation and lifecycle/Composer/Add menu", component: ComposerAddStory, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/composer-mobile-assignee.stories.tsx b/ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx similarity index 84% rename from ui/storybook/stories/composer-mobile-assignee.stories.tsx rename to ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx index 1f5f8d70b17..d72f59ff0b7 100644 --- a/ui/storybook/stories/composer-mobile-assignee.stories.tsx +++ b/ui/storybook/stories/tasks/composer-mobile-assignee.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; -import { ComposerRunSettingsLiveStory } from "../prototypes/composer-model-picker/ComposerRunSettingsLiveStory"; +import { ComposerRunSettingsLiveStory } from "../../prototypes/composer-model-picker/ComposerRunSettingsLiveStory"; const meta = { - title: "Composer/Mobile assignee picker", + id: "composer-mobile-assignee-picker", + title: "Tasks and conversations/Task creation and lifecycle/Composer/Mobile assignee picker", component: ComposerRunSettingsLiveStory, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/composer-model-picker.stories.tsx b/ui/storybook/stories/tasks/composer-model-picker.stories.tsx similarity index 98% rename from ui/storybook/stories/composer-model-picker.stories.tsx rename to ui/storybook/stories/tasks/composer-model-picker.stories.tsx index b99938b62fe..6f3ff357324 100644 --- a/ui/storybook/stories/composer-model-picker.stories.tsx +++ b/ui/storybook/stories/tasks/composer-model-picker.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; -import { ComposerModelPickerPreview } from "../prototypes/composer-model-picker/ComposerModelPickerPreview"; -import { ComposerRunSettingsLiveStory } from "../prototypes/composer-model-picker/ComposerRunSettingsLiveStory"; +import { ComposerModelPickerPreview } from "../../prototypes/composer-model-picker/ComposerModelPickerPreview"; +import { ComposerRunSettingsLiveStory } from "../../prototypes/composer-model-picker/ComposerRunSettingsLiveStory"; const meta = { - title: "Composer/Model and effort picker", + id: "composer-model-and-effort-picker", + title: "Tasks and conversations/Task creation and lifecycle/Composer/Model and effort picker", component: ComposerModelPickerPreview, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/control-plane-surfaces.stories.tsx b/ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx similarity index 98% rename from ui/storybook/stories/control-plane-surfaces.stories.tsx rename to ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx index 98e4a1fc5cb..13b559243f3 100644 --- a/ui/storybook/stories/control-plane-surfaces.stories.tsx +++ b/ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx @@ -18,7 +18,7 @@ import { storybookEntityNameMap, storybookEntityTitleMap, storybookIssues, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; function Section({ eyebrow, @@ -247,7 +247,8 @@ function ControlPlaneSurfaces() { } const meta = { - title: "Product/Control Plane Surfaces", + id: "product-control-plane-surfaces", + title: "Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces", component: ControlPlaneSurfaces, parameters: { docs: { diff --git a/ui/storybook/stories/dialogs-modals.stories.tsx b/ui/storybook/stories/tasks/dialogs-modals.stories.tsx similarity index 99% rename from ui/storybook/stories/dialogs-modals.stories.tsx rename to ui/storybook/stories/tasks/dialogs-modals.stories.tsx index 60d7ba8063b..3eb5327e95f 100644 --- a/ui/storybook/stories/dialogs-modals.stories.tsx +++ b/ui/storybook/stories/tasks/dialogs-modals.stories.tsx @@ -30,7 +30,7 @@ import { storybookIssueLabels, storybookIssues, storybookProjects, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const SELECTED_COMPANY_STORAGE_KEY = "paperclip.selectedCompanyId"; @@ -674,7 +674,8 @@ function PathInstructionsModalStory() { } const meta = { - title: "Product/Dialogs & Modals", + id: "product-dialogs-modals", + title: "Tasks and conversations/Task creation and lifecycle/Product/Dialogs & Modals", parameters: { docs: { description: { diff --git a/ui/storybook/stories/external-objects.stories.tsx b/ui/storybook/stories/tasks/external-objects.stories.tsx similarity index 99% rename from ui/storybook/stories/external-objects.stories.tsx rename to ui/storybook/stories/tasks/external-objects.stories.tsx index e0e91816dae..7e45ad9b13d 100644 --- a/ui/storybook/stories/external-objects.stories.tsx +++ b/ui/storybook/stories/tasks/external-objects.stories.tsx @@ -35,7 +35,7 @@ import { storybookIssueLabels, storybookIssues, storybookProjects, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; function makeObject(args: { id: string; @@ -672,7 +672,8 @@ function IntegrationSurfacesStory() { } const meta = { - title: "Foundations/External Objects", + id: "foundations-external-objects", + title: "Tasks and conversations/Task creation and lifecycle/External Objects", component: StateMatrixStory, parameters: { docs: { diff --git a/ui/storybook/stories/issue-management.stories.tsx b/ui/storybook/stories/tasks/issue-management.stories.tsx similarity index 99% rename from ui/storybook/stories/issue-management.stories.tsx rename to ui/storybook/stories/tasks/issue-management.stories.tsx index e1774d82d92..ae060fdb15d 100644 --- a/ui/storybook/stories/issue-management.stories.tsx +++ b/ui/storybook/stories/tasks/issue-management.stories.tsx @@ -49,7 +49,7 @@ import { storybookIssueRuns, storybookIssues, storybookProjects, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const companyId = "company-storybook"; const issueListViewKey = "storybook:issue-management:list"; @@ -872,7 +872,8 @@ function IssueManagementStories() { } const meta = { - title: "Product/Issue Management", + id: "product-issue-management", + title: "Tasks and conversations/Task creation and lifecycle/Product/Issue Management", component: IssueManagementStories, parameters: { docs: { diff --git a/ui/storybook/stories/mobile-entity-pickers.stories.tsx b/ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx similarity index 94% rename from ui/storybook/stories/mobile-entity-pickers.stories.tsx rename to ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx index ea06c277d49..f01c3efadc0 100644 --- a/ui/storybook/stories/mobile-entity-pickers.stories.tsx +++ b/ui/storybook/stories/tasks/mobile-entity-pickers.stories.tsx @@ -41,7 +41,8 @@ function OpenPicker({ kind, options }: { kind: "Assignee" | "Project"; options: } const meta = { - title: "Components/Entity pickers/Mobile", + id: "components-entity-pickers-mobile", + title: "Tasks and conversations/Task creation and lifecycle/Components/Entity pickers/Mobile", parameters: { layout: "fullscreen", viewport: { defaultViewport: "mobile1" }, diff --git a/ui/storybook/stories/new-task-composer.stories.tsx b/ui/storybook/stories/tasks/new-task-composer.stories.tsx similarity index 99% rename from ui/storybook/stories/new-task-composer.stories.tsx rename to ui/storybook/stories/tasks/new-task-composer.stories.tsx index 73fd9a8275d..5cebf9df7b6 100644 --- a/ui/storybook/stories/new-task-composer.stories.tsx +++ b/ui/storybook/stories/tasks/new-task-composer.stories.tsx @@ -25,7 +25,7 @@ import { storybookExecutionWorkspaces, storybookProjects, storybookIssues, -} from "../fixtures/paperclipData"; +} from "../../fixtures/paperclipData"; const COMPANY_ID = "company-storybook"; const NEW_TASK_AGENTS = [...storybookAgents, { ...storybookAgents[2]!, id: "agent-ceo", name: "CEO", role: "ceo" }].map((agent) => @@ -203,7 +203,8 @@ function NewTaskStory({ } const meta = { - title: "Composer/New task", + id: "composer-new-task", + title: "Tasks and conversations/Task creation and lifecycle/Composer/New task", component: NewTaskStory, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/runner-activity.stories.tsx b/ui/storybook/stories/tasks/runner-activity.stories.tsx similarity index 93% rename from ui/storybook/stories/runner-activity.stories.tsx rename to ui/storybook/stories/tasks/runner-activity.stories.tsx index 17aea163b4f..829817578a9 100644 --- a/ui/storybook/stories/runner-activity.stories.tsx +++ b/ui/storybook/stories/tasks/runner-activity.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { MINIMAL_VIEWPORTS } from "storybook/viewport"; -import { RunnerActivityPreview } from "../prototypes/runner-activity/RunnerActivityPreview"; +import { RunnerActivityPreview } from "../../prototypes/runner-activity/RunnerActivityPreview"; const meta = { - title: "Tasks/Runner activity preview", + id: "tasks-runner-activity-preview", + title: "Tasks and conversations/Task creation and lifecycle/Tasks/Runner activity preview", component: RunnerActivityPreview, globals: { viewport: { value: "desktop", isRotated: false } }, parameters: { diff --git a/ui/storybook/stories/successful-run-handoff.stories.tsx b/ui/storybook/stories/tasks/successful-run-handoff.stories.tsx similarity index 97% rename from ui/storybook/stories/successful-run-handoff.stories.tsx rename to ui/storybook/stories/tasks/successful-run-handoff.stories.tsx index 18085a7dee4..5044a46281d 100644 --- a/ui/storybook/stories/successful-run-handoff.stories.tsx +++ b/ui/storybook/stories/tasks/successful-run-handoff.stories.tsx @@ -13,7 +13,7 @@ import { SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION, successfulRunHandoffActivityTone, } from "@/lib/successful-run-handoff"; -import { createIssue, storybookAgents } from "../fixtures/paperclipData"; +import { createIssue, storybookAgents } from "../../fixtures/paperclipData"; function ActivityExample({ action }: { action: string }) { const tone = successfulRunHandoffActivityTone(action); @@ -238,7 +238,8 @@ function SuccessfulRunHandoffEscalationComment() { } const meta = { - title: "Paperclip/Successful Run Handoff", + id: "paperclip-successful-run-handoff", + title: "Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff", component: SuccessfulRunHandoffStates, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/task-chat-chain-of-thought.stories.tsx b/ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx similarity index 97% rename from ui/storybook/stories/task-chat-chain-of-thought.stories.tsx rename to ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx index 4719d135fca..a2346dab7dd 100644 --- a/ui/storybook/stories/task-chat-chain-of-thought.stories.tsx +++ b/ui/storybook/stories/tasks/task-chat-chain-of-thought.stories.tsx @@ -159,7 +159,8 @@ function ReconnectingAlignmentReview() { } const meta = { - title: "Tasks/Task chat review fixes", + id: "tasks-task-chat-review-fixes", + title: "Tasks and conversations/Task creation and lifecycle/Tasks/Task chat review fixes", component: ChainOfThoughtReview, parameters: { layout: "padded" }, } satisfies Meta; diff --git a/ui/storybook/stories/task-chat-mobile-follow.stories.tsx b/ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx similarity index 95% rename from ui/storybook/stories/task-chat-mobile-follow.stories.tsx rename to ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx index 1fb4d9b3cad..9454036c6e6 100644 --- a/ui/storybook/stories/task-chat-mobile-follow.stories.tsx +++ b/ui/storybook/stories/tasks/task-chat-mobile-follow.stories.tsx @@ -44,7 +44,8 @@ function MobileFollow() { } const meta = { - title: "Task chat/Mobile streaming follow", + id: "task-chat-mobile-streaming-follow", + title: "Tasks and conversations/Task creation and lifecycle/Task chat/Mobile streaming follow", component: MobileFollow, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/task-chat-system-notice.stories.tsx b/ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx similarity index 94% rename from ui/storybook/stories/task-chat-system-notice.stories.tsx rename to ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx index 553e8e24de4..13c138382d6 100644 --- a/ui/storybook/stories/task-chat-system-notice.stories.tsx +++ b/ui/storybook/stories/tasks/task-chat-system-notice.stories.tsx @@ -53,7 +53,8 @@ function StoryFrame({ item }: { item: TaskChatMessageItem }) { } const meta = { - title: "Product/Task chat/System notice", + id: "product-task-chat-system-notice", + title: "Tasks and conversations/Task creation and lifecycle/Product/Task chat/System notice", component: TaskChatSystemNotice, args: { item: workspaceReadyItem, diff --git a/ui/storybook/stories/task-context-notices.stories.tsx b/ui/storybook/stories/tasks/task-context-notices.stories.tsx similarity index 96% rename from ui/storybook/stories/task-context-notices.stories.tsx rename to ui/storybook/stories/tasks/task-context-notices.stories.tsx index f92b8ffaaac..945085fb208 100644 --- a/ui/storybook/stories/task-context-notices.stories.tsx +++ b/ui/storybook/stories/tasks/task-context-notices.stories.tsx @@ -45,7 +45,8 @@ function TaskContextNotices() { } const meta = { - title: "Tasks/Context notices", + id: "tasks-context-notices", + title: "Tasks and conversations/Task creation and lifecycle/Tasks/Context notices", component: TaskContextNotices, parameters: { layout: "fullscreen" }, } satisfies Meta; diff --git a/ui/storybook/stories/thread-human-identity/00-evidence.stories.tsx b/ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx similarity index 85% rename from ui/storybook/stories/thread-human-identity/00-evidence.stories.tsx rename to ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx index 13089428aeb..db6f71466bf 100644 --- a/ui/storybook/stories/thread-human-identity/00-evidence.stories.tsx +++ b/ui/storybook/stories/tasks/thread-human-identity/00-evidence.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { humanMessages, profiles } from "./fixtures"; +import { humanMessages, profiles } from "../../../support/thread-human-identity/fixtures"; function ReviewGuide() { return

Human identity in a mixed task conversation

@@ -10,5 +10,6 @@ function ReviewGuide() {

These are representative fixtures, not a copy of private task data. The mixed journey uses the production shell, adapter, thread, and composer. Swap the viewer to verify that ownership follows authorUserId. Full Conversation includes all fixture messages. Sending is local to the preview.

; } -export default { title: "Chat & Comments/Human identity/00 Review guide", component: ReviewGuide } satisfies Meta; +export default { id: "chat-comments-human-identity-00-review-guide", + title: "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/00 Review guide", component: ReviewGuide } satisfies Meta; export const DataFlow: StoryObj = {}; diff --git a/ui/storybook/stories/thread-human-identity/01-components.stories.tsx b/ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx similarity index 88% rename from ui/storybook/stories/thread-human-identity/01-components.stories.tsx rename to ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx index db5af49ab97..f95ac6e2b64 100644 --- a/ui/storybook/stories/thread-human-identity/01-components.stories.tsx +++ b/ui/storybook/stories/tasks/thread-human-identity/01-components.stories.tsx @@ -3,7 +3,7 @@ import { expect, within } from "storybook/test"; import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble"; import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter"; import type { TaskChatMessageItem } from "@/components/task-chat/task-chat-model"; -import { comments, profiles, alexId, samId } from "./fixtures"; +import { comments, profiles, alexId, samId } from "../../../support/thread-human-identity/fixtures"; function Bubble({ viewer = alexId, photo = null, unresolved = false }: { viewer?: string; photo?: string | null; unresolved?: boolean }) { const userProfileMap = new Map(profiles); @@ -12,7 +12,8 @@ function Bubble({ viewer = alexId, photo = null, unresolved = false }: { viewer? return
; } const meta = { - title: "Chat & Comments/Human identity/01 Components", component: Bubble, + id: "chat-comments-human-identity-01-components", + title: "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/01 Components", component: Bubble, args: { viewer: alexId, photo: null }, parameters: { layout: "padded", docs: { description: { component: "Production adapter → model → TaskChatBubble → Identity. Other humans sit on the left on the same page surface as agents with their own name/photo; no-photo profiles use the shared initials fallback. Edit photo to exercise an uploaded Paperclip asset URL. External URLs use initials. The production avatar URL is taken from the company directory, never from the current viewer or the relay bot." } } }, } satisfies Meta; diff --git a/ui/storybook/stories/thread-human-identity/02-task-journey.stories.tsx b/ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx similarity index 93% rename from ui/storybook/stories/thread-human-identity/02-task-journey.stories.tsx rename to ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx index ae6dd2699b0..0fea4b3e70b 100644 --- a/ui/storybook/stories/thread-human-identity/02-task-journey.stories.tsx +++ b/ui/storybook/stories/tasks/thread-human-identity/02-task-journey.stories.tsx @@ -7,8 +7,8 @@ import { Layout } from "@/components/Layout"; import { TaskChatThread } from "@/components/TaskChatThread"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { queryKeys } from "@/lib/queryKeys"; -import { storybookAuthSession } from "../../fixtures/paperclipData"; -import { mixedComments as initialComments, profiles, alexId, samId, issueId, agentMap } from "./fixtures"; +import { storybookAuthSession } from "../../../fixtures/paperclipData"; +import { mixedComments as initialComments, profiles, alexId, samId, issueId, agentMap } from "../../../support/thread-human-identity/fixtures"; function Conversation({ viewer = alexId, fullHistory = false }: { viewer?: string; fullHistory?: boolean }) { const [comments, setComments] = useState(() => fullHistory ? initialComments : [...initialComments.slice(0, 2), ...initialComments.slice(-2)]); @@ -43,7 +43,8 @@ function Journey(props: { viewer?: string; fullHistory?: boolean }) {
; } const meta = { - title: "Chat & Comments/Human identity/02 Mixed-author journey", component: Journey, + id: "chat-comments-human-identity-02-mixed-author-journey", + title: "Tasks and conversations/Task creation and lifecycle/Chat & Comments/Human identity/02 Mixed-author journey", component: Journey, args: { viewer: alexId, fullHistory: false }, parameters: { layout: "fullscreen", initialEntries: ["/PAP/issues/human-identity-example"], docs: { story: { inline: false }, description: { component: "Representative turns from two humans and an agent in the production app shell and TaskChatThread, including its composer. Photos remain null so the shared initials fallback is visible. Swap the viewer: identity follows authorUserId. The default excerpt fits all three speakers in one view; Full Conversation includes all fixture messages. Sending is local to this preview." } } }, } satisfies Meta; diff --git a/ui/storybook/stories/team-catalog.stories.tsx b/ui/storybook/stories/teams/team-catalog.stories.tsx similarity index 99% rename from ui/storybook/stories/team-catalog.stories.tsx rename to ui/storybook/stories/teams/team-catalog.stories.tsx index 922f1cf304b..f4359fb4b38 100644 --- a/ui/storybook/stories/team-catalog.stories.tsx +++ b/ui/storybook/stories/teams/team-catalog.stories.tsx @@ -139,7 +139,8 @@ function Frame({ children, width = "max-w-3xl" }: { children: React.ReactNode; w } const meta: Meta = { - title: "Surfaces/Team Catalog", + id: "surfaces-team-catalog", + title: "Agents and reusable capabilities/Team packages and installation/Surfaces/Team Catalog", }; export default meta; type Story = StoryObj; diff --git a/ui/storybook/stories/browser-use-activity.stories.tsx b/ui/storybook/stories/workspaces/browser-use-activity.stories.tsx similarity index 94% rename from ui/storybook/stories/browser-use-activity.stories.tsx rename to ui/storybook/stories/workspaces/browser-use-activity.stories.tsx index 1554a7a8b64..bd359555029 100644 --- a/ui/storybook/stories/browser-use-activity.stories.tsx +++ b/ui/storybook/stories/workspaces/browser-use-activity.stories.tsx @@ -8,9 +8,10 @@ import { BrowserStoryProviders, InteractiveBrowserStory, mockBrowserUse, -} from "../fixtures/browser-use"; +} from "../../fixtures/browser-use"; const meta = { - title: "Tasks/Browser Use/Task activity", + id: "tasks-browser-use-task-activity", + title: "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Task activity", component: TaskBrowserActivity, parameters: { layout: "centered", diff --git a/ui/storybook/stories/browser-use-footer.stories.tsx b/ui/storybook/stories/workspaces/browser-use-footer.stories.tsx similarity index 96% rename from ui/storybook/stories/browser-use-footer.stories.tsx rename to ui/storybook/stories/workspaces/browser-use-footer.stories.tsx index f62b4f75a5d..a51134420ff 100644 --- a/ui/storybook/stories/browser-use-footer.stories.tsx +++ b/ui/storybook/stories/workspaces/browser-use-footer.stories.tsx @@ -7,7 +7,7 @@ import type { TaskBrowser, } from "@paperclipai/shared"; import { TaskBrowserFooter } from "@/components/task-side-panel/TaskBrowserFooter"; -import { browserFixture } from "../fixtures/browser-use"; +import { browserFixture } from "../../fixtures/browser-use"; const now = Date.parse("2026-09-29T16:00:00Z"); const idle = (seconds: number): TaskBrowser => ({ ...browserFixture, @@ -15,7 +15,8 @@ const idle = (seconds: number): TaskBrowser => ({ idleDeadline: new Date(now + seconds * 1000).toISOString(), }); const meta = { - title: "Tasks/Browser Use/Footer", + id: "tasks-browser-use-footer", + title: "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Footer", component: TaskBrowserFooter, parameters: { layout: "centered", diff --git a/ui/storybook/stories/browser-use.stories.tsx b/ui/storybook/stories/workspaces/browser-use.stories.tsx similarity index 97% rename from ui/storybook/stories/browser-use.stories.tsx rename to ui/storybook/stories/workspaces/browser-use.stories.tsx index 608054f1dbc..f132beed510 100644 --- a/ui/storybook/stories/browser-use.stories.tsx +++ b/ui/storybook/stories/workspaces/browser-use.stories.tsx @@ -4,17 +4,18 @@ import { expect, userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { TaskSidePanel } from "@/components/task-side-panel/TaskSidePanel"; import { TaskBrowserPanel } from "@/components/task-side-panel/TaskBrowserPanel"; -import { storybookIssues } from "../fixtures/paperclipData"; +import { storybookIssues } from "../../fixtures/paperclipData"; import { BrowserStoryProviders, InteractiveBrowserStory, browserFixture, browserState, mockBrowserUse, -} from "../fixtures/browser-use"; +} from "../../fixtures/browser-use"; const meta = { - title: "Tasks/Browser Use/Panel", + id: "tasks-browser-use-panel", + title: "Projects and execution/Workspaces, services, and files/Tasks/Browser Use/Panel", component: TaskBrowserPanel, parameters: { layout: "fullscreen", diff --git a/ui/storybook/stories/searchable-select.stories.tsx b/ui/storybook/stories/workspaces/searchable-select.stories.tsx similarity index 98% rename from ui/storybook/stories/searchable-select.stories.tsx rename to ui/storybook/stories/workspaces/searchable-select.stories.tsx index 12d657245e8..1600f40fcaa 100644 --- a/ui/storybook/stories/searchable-select.stories.tsx +++ b/ui/storybook/stories/workspaces/searchable-select.stories.tsx @@ -185,7 +185,8 @@ function FormContext({ triggerClassName }: { triggerClassName?: string }) { } const meta = { - title: "Components/SearchableSelect/Workspace picker", + id: "components-searchableselect-workspace-picker", + title: "Projects and execution/Workspaces, services, and files/Components/SearchableSelect/Workspace picker", parameters: { layout: "centered" }, } satisfies Meta; diff --git a/ui/storybook/stories/workspace-file-browser.stories.tsx b/ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx similarity index 98% rename from ui/storybook/stories/workspace-file-browser.stories.tsx rename to ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx index b33d8eda98f..72bc848e765 100644 --- a/ui/storybook/stories/workspace-file-browser.stories.tsx +++ b/ui/storybook/stories/workspaces/workspace-file-browser.stories.tsx @@ -197,7 +197,8 @@ function Placement() { } const meta: Meta = { - title: "Issue/Workspace File Browser", + id: "issue-workspace-file-browser", + title: "Projects and execution/Workspaces, services, and files/Issue/Workspace File Browser", parameters: { layout: "fullscreen" }, }; export default meta; diff --git a/ui/storybook/stories/workspace-runtime-provisioning.stories.tsx b/ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx similarity index 95% rename from ui/storybook/stories/workspace-runtime-provisioning.stories.tsx rename to ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx index edfe1f6ccf6..7dfdcc6c46d 100644 --- a/ui/storybook/stories/workspace-runtime-provisioning.stories.tsx +++ b/ui/storybook/stories/workspaces/workspace-runtime-provisioning.stories.tsx @@ -37,7 +37,8 @@ function ContextCard({ status }: { status: RuntimeProvisionStatus }) { } const meta: Meta = { - title: "Workspaces/Runtime provisioning status", + id: "workspaces-runtime-provisioning-status", + title: "Projects and execution/Workspaces, services, and files/Workspaces/Runtime provisioning status", component: RuntimeProvisionStatusValue, parameters: { layout: "padded" }, }; diff --git a/ui/storybook/stories/workspace-service-control-bar.stories.tsx b/ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx similarity index 97% rename from ui/storybook/stories/workspace-service-control-bar.stories.tsx rename to ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx index 62aef3243a3..f542218f387 100644 --- a/ui/storybook/stories/workspace-service-control-bar.stories.tsx +++ b/ui/storybook/stories/workspaces/workspace-service-control-bar.stories.tsx @@ -20,7 +20,8 @@ function entry(overrides: Partial = {}): Workspace } const meta: Meta = { - title: "Workspaces/Service control bar", + id: "workspaces-service-control-bar", + title: "Projects and execution/Workspaces, services, and files/Workspaces/Service control bar", component: WorkspaceServiceControlBar, parameters: { layout: "padded", diff --git a/ui/storybook/stories/artifact-cards/README.md b/ui/storybook/support/artifact-cards/README.md similarity index 96% rename from ui/storybook/stories/artifact-cards/README.md rename to ui/storybook/support/artifact-cards/README.md index 76d9d8f0d5b..6734b88c4be 100644 --- a/ui/storybook/stories/artifact-cards/README.md +++ b/ui/storybook/support/artifact-cards/README.md @@ -1,6 +1,6 @@ # Artifact cards -Storybook: **Explorations → Artifact cards**. Each story renders one production +Storybook: **Tasks and conversations → Documents, attachments, and work products → Explorations → Artifact cards**. Each story renders one production card from `ui/src/components/artifacts/RichArtifactCards.tsx`. All example titles, authors, content, filenames, repository names, and URLs live in story args. Use **Controls** to edit values, including Markdown bodies and JSON table data. diff --git a/ui/storybook/stories/artifact-cards/storyConfig.tsx b/ui/storybook/support/artifact-cards/storyConfig.tsx similarity index 100% rename from ui/storybook/stories/artifact-cards/storyConfig.tsx rename to ui/storybook/support/artifact-cards/storyConfig.tsx diff --git a/ui/storybook/stories/artifact-upload/UploadPreview.tsx b/ui/storybook/support/artifact-upload/UploadPreview.tsx similarity index 100% rename from ui/storybook/stories/artifact-upload/UploadPreview.tsx rename to ui/storybook/support/artifact-upload/UploadPreview.tsx diff --git a/ui/storybook/stories/artifact-upload/fixtures.ts b/ui/storybook/support/artifact-upload/fixtures.ts similarity index 100% rename from ui/storybook/stories/artifact-upload/fixtures.ts rename to ui/storybook/support/artifact-upload/fixtures.ts diff --git a/ui/storybook/stories/credential-access/CredentialTaskPage.tsx b/ui/storybook/support/credential-access/CredentialTaskPage.tsx similarity index 100% rename from ui/storybook/stories/credential-access/CredentialTaskPage.tsx rename to ui/storybook/support/credential-access/CredentialTaskPage.tsx diff --git a/ui/storybook/stories/decision-models/fixtures.ts b/ui/storybook/support/decision-models/fixtures.ts similarity index 100% rename from ui/storybook/stories/decision-models/fixtures.ts rename to ui/storybook/support/decision-models/fixtures.ts diff --git a/ui/storybook/stories/external-agent-invite/ExternalAgentInvitePreview.tsx b/ui/storybook/support/external-agent-invite/ExternalAgentInvitePreview.tsx similarity index 100% rename from ui/storybook/stories/external-agent-invite/ExternalAgentInvitePreview.tsx rename to ui/storybook/support/external-agent-invite/ExternalAgentInvitePreview.tsx diff --git a/ui/storybook/stories/external-agent-invite/fixtures.ts b/ui/storybook/support/external-agent-invite/fixtures.ts similarity index 100% rename from ui/storybook/stories/external-agent-invite/fixtures.ts rename to ui/storybook/support/external-agent-invite/fixtures.ts diff --git a/ui/storybook/stories/github-bot-management/fixture-api.test.tsx b/ui/storybook/support/github-bot-management/fixture-api.test.tsx similarity index 100% rename from ui/storybook/stories/github-bot-management/fixture-api.test.tsx rename to ui/storybook/support/github-bot-management/fixture-api.test.tsx diff --git a/ui/storybook/stories/github-bot-management/fixture-api.tsx b/ui/storybook/support/github-bot-management/fixture-api.tsx similarity index 100% rename from ui/storybook/stories/github-bot-management/fixture-api.tsx rename to ui/storybook/support/github-bot-management/fixture-api.tsx diff --git a/ui/storybook/stories/github-bot-management/fixtures.ts b/ui/storybook/support/github-bot-management/fixtures.ts similarity index 100% rename from ui/storybook/stories/github-bot-management/fixtures.ts rename to ui/storybook/support/github-bot-management/fixtures.ts diff --git a/ui/storybook/stories/html-artifacts/fixtures.ts b/ui/storybook/support/html-artifacts/fixtures.ts similarity index 100% rename from ui/storybook/stories/html-artifacts/fixtures.ts rename to ui/storybook/support/html-artifacts/fixtures.ts diff --git a/ui/storybook/stories/html-artifacts/report.html b/ui/storybook/support/html-artifacts/report.html similarity index 100% rename from ui/storybook/stories/html-artifacts/report.html rename to ui/storybook/support/html-artifacts/report.html diff --git a/ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx b/ui/storybook/support/primary-agent/PrimaryAgentStory.tsx similarity index 100% rename from ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx rename to ui/storybook/support/primary-agent/PrimaryAgentStory.tsx diff --git a/ui/storybook/stories/private-tasks/PrivacyStory.tsx b/ui/storybook/support/private-tasks/PrivacyStory.tsx similarity index 100% rename from ui/storybook/stories/private-tasks/PrivacyStory.tsx rename to ui/storybook/support/private-tasks/PrivacyStory.tsx diff --git a/ui/storybook/stories/public-mcp-presenter.tsx b/ui/storybook/support/public-mcp-presenter.tsx similarity index 100% rename from ui/storybook/stories/public-mcp-presenter.tsx rename to ui/storybook/support/public-mcp-presenter.tsx diff --git a/ui/storybook/stories/repository-skill-packages/README.md b/ui/storybook/support/repository-skill-packages/README.md similarity index 68% rename from ui/storybook/stories/repository-skill-packages/README.md rename to ui/storybook/support/repository-skill-packages/README.md index 738b47bd2c1..ffe1e47b799 100644 --- a/ui/storybook/stories/repository-skill-packages/README.md +++ b/ui/storybook/support/repository-skill-packages/README.md @@ -1,6 +1,6 @@ # Repository imports -Run the UI Storybook, then open **Skills → Repository packages**. These stories use the production pages and components with scoped API fixtures; they do not download GitHub repositories or invoke agents. +Run the UI Storybook, then open **Agents and reusable capabilities → Skills and Skill Studio → Skills → Repository packages**. These stories use the production pages and components with scoped API fixtures; they do not download GitHub repositories or invoke agents. - **01–03**: normal repository import and shared-file inspection. Every skill is visible and selected; package files start collapsed. - **04–05**: omitted files, collapsed and expanded warnings; import stays available. diff --git a/ui/storybook/stories/thread-human-identity/fixtures.ts b/ui/storybook/support/thread-human-identity/fixtures.ts similarity index 100% rename from ui/storybook/stories/thread-human-identity/fixtures.ts rename to ui/storybook/support/thread-human-identity/fixtures.ts diff --git a/ui/storybook/stories/workspace-branch-recovery/fixtures.ts b/ui/storybook/support/workspace-branch-recovery/fixtures.ts similarity index 100% rename from ui/storybook/stories/workspace-branch-recovery/fixtures.ts rename to ui/storybook/support/workspace-branch-recovery/fixtures.ts