From e364b92c828e13fb890aa34b0af2aaa6145f4249 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:40:01 -0500 Subject: [PATCH 1/6] docs: add PR rules for file size and code comments (#16036) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Contributors use CONTRIBUTING.md to prepare pull requests. > - The guide is missing two code submission rules. > - Contributors need to see these rules before they submit changes. > - This pull request adds the file size and code comment rules to the requirements for all PRs. > - The benefit is a clear statement of the requested contribution policy. ## Linked Issues or Issue Description **Issue type** Missing documentation. **Where is the issue?** CONTRIBUTING.md, PR Requirements (all PRs). **What's wrong?** The guide does not state the requested limits on file growth and code comments. **Suggested fix** Add both rules with the wording requested by the maintainer. No related issue or PR was found in the GitHub search. ## What Changed - Add: "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." - Add: "Do not submit a PR that includes code comments." ## Verification - Confirm both rules appear under PR Requirements (all PRs) in CONTRIBUTING.md. - `git diff --check origin/master...HEAD` passed. - Greptile passed on commit `8cc5cd4be63cfbb16b3265b39e487581387bc3d0` with 5/5 and no actionable findings. - Local `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` were attempted. This worktree has no installed dependencies, so typecheck cannot resolve Node types and tests/build cannot load tsx. GitHub CI results will be checked before merge. ## Risks - Low risk. This change adds documentation only. - The wording states contribution policy without adding an automated gate. ## Model Used - OpenAI GPT-6 via Codex. Used reasoning, shell tools, and file editing. The exact runtime model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (not applicable to this documentation-only change; the full commands were attempted but blocked by missing local dependencies) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) 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. From 8e7b5db822ac69893da3857241c36e30b962786b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:14:35 -0500 Subject: [PATCH 2/6] ci: add protected routing workflow for reserved AWS capacity (#16039) ## Thinking Path > - Paperclip uses CI to verify changes before release. > - Trusted PR tests can run on isolated AWS workers. > - Their runner-selection job currently waits for GitHub-hosted capacity. > - A large hosted test queue therefore blocks admission to AWS. > - A dedicated routing workflow lets us reserve capacity for metadata validation. > - This PR adds that workflow without changing the active PR caller. ## Linked Issues or Issue Description **Issue type** CI reliability enhancement. **Where is the issue?** The Select trusted runner job in PR CI. **What's wrong?** The routing job competes with a large hosted test queue. Its sampled median wait was about 20 minutes. AWS tests cannot begin before this job finishes. **Suggested fix** Add a protected reusable routing workflow. Deploy its dedicated AWS pool, then switch the PR caller in a follow-up PR. No overlapping open PR or issue was found. ## What Changed - Add ci-routing.yml with the existing author, sender, rerun actor, repository, head, merge, and stack checks. - Target the dedicated paperclip-ci-routing-x64 Fleet. - Export runner and full_ci decisions to reusable workflow callers. - Add tests for metadata-only execution, reserved placement, and output forwarding. - Keep the active PR caller unchanged until the pool is deployed. ## Verification - The routing tests passed (30 tests), including 20 executed runner-selection cases and 8 executed stack-scope cases. - Existing routing allow/deny scenarios also passed with the extracted script (16 cases). - A live qualification job selected AWS and passed on the reserved routing pool in https://github.com/paperclipai/paperclip/actions/runs/38308312153/job/115007621128 while the hosted gate remained queued. - actionlint passed with the dedicated custom runner label declared in its local configuration. - git diff --check passed. - Full pnpm typecheck/test/build cannot run in this worktree because dependencies are not installed. The change is limited to workflow YAML and Node tests. - After merge, configure the restricted runner group, deploy the pool, and dispatch ci-routing.yml on master. The unused workflow does not affect current PR placement. ## Risks - The maintainer explicitly approved merging this rollout after current-head Greptile 5/5 and focused verification, while the hosted CI checks remain queued. - The dedicated Fleet must exist before the workflow can receive a runner. This PR introduces the workflow without switching active PR CI. - The group must authorize only ci-routing.yml on protected master and canonical repository ID 1170821064. - The routing workflow runs no checkout or PR code and has read-only permissions. The test admission checks are unchanged. ## Model Used - OpenAI GPT-6 via Codex, with reasoning, shell tools, and file editing. The exact runtime model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused routing and workflow tests; full pnpm commands are blocked by missing worktree dependencies) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] Required CI was checked; the maintainer explicitly approved an admin bypass for queued checks after clean review - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .github/scripts/tests/ci-routing.test.mjs | 137 ++++++++++++ .github/workflows/ci-routing.yml | 252 ++++++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 .github/scripts/tests/ci-routing.test.mjs create mode 100644 .github/workflows/ci-routing.yml diff --git a/.github/scripts/tests/ci-routing.test.mjs b/.github/scripts/tests/ci-routing.test.mjs new file mode 100644 index 00000000000..fedc277482a --- /dev/null +++ b/.github/scripts/tests/ci-routing.test.mjs @@ -0,0 +1,137 @@ +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"; + +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"]); + assert.match(jobs, /^ {4}runs-on: runs-on\/fleet=paperclip-ci-routing-x64\/env=public-ci$/m); + 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); + }); +} diff --git a/.github/workflows/ci-routing.yml b/.github/workflows/ci-routing.yml new file mode 100644 index 00000000000..b8d2e93fa5c --- /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: runs-on/fleet=paperclip-ci-routing-x64/env=public-ci + 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" + From 2b8da4ab6466933dd5f4f95b4061249158b5046e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 12 Oct 2026 14:17:41 -0700 Subject: [PATCH 3/6] docs(release): canonicalize v2026.1012.0 release notes (#16045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The release process keeps one curated notes file for each release in `releases/` > - Beta notes live in `releases/beta/` until the beta becomes a stable release > - v2026.1012.0 was published from beta 2026.1006.0-beta.0 on 2026-10-12 and its notes file still has the beta path on master > - The release workflow did not push its usual canonicalize branch because the notes came from the source tree of the release candidate > - This pull request renames the beta notes file to the stable path > - The benefit is that the stable notes link `releases/v2026.1012.0.md` resolves on master and the beta path does not stay as a duplicate ## Linked Issues or Issue Description **Describe the problem** The stable release v2026.1012.0 is published. The GitHub Release body is the notes file that PR #16000 merged. On master, that file is still at `releases/beta/v2026.1006.0-beta.0.md`. The stable path `releases/v2026.1012.0.md` does not exist on master. Links to the stable path return 404. **Expected behavior** The notes for v2026.1012.0 are at `releases/v2026.1012.0.md` on master. The beta path is removed. The content is identical to the GitHub Release body. **Additional context** Related: #16000 (pre-publish notes fix) and #15388 (curated beta notes). ## What Changed - Rename `releases/beta/v2026.1006.0-beta.0.md` to `releases/v2026.1012.0.md`. - This is a pure rename. The file content does not change. Git reports `R100`. ## Verification - Run `git diff -M --name-status master...release-notes/v2026.1012.0-canonicalize`. The output is one line: `R100 releases/beta/v2026.1006.0-beta.0.md releases/v2026.1012.0.md`. - Compare the file with the GitHub Release body at https://github.com/paperclipai/paperclip/releases/tag/v2026.1012.0. The content is the same. The git blob is `e7503b64aa5bcb74b2da6bb05a7da2554919e0ec` on both master and this branch. - The git tag `v2026.1012.0` points at the release candidate commit `bba6a45f0d364cfd962ac59b3bdf2426a8222adb`. That commit already has the same file at `releases/v2026.1012.0.md` with the same blob. ## Risks - Low risk. This is a documentation rename with no content change. No code or config changes. ## Model Used - Claude (Anthropic), model ID `claude-fable-5-1`, used through Claude Code with tool use and extended reasoning. The rename was done with `git mv`. No AI-generated content is in the file. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- releases/{beta/v2026.1006.0-beta.0.md => v2026.1012.0.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename releases/{beta/v2026.1006.0-beta.0.md => v2026.1012.0.md} (100%) 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 From f5346c05b923a1eb1d15d04d8e08b993a7c4d097 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:47:57 -0500 Subject: [PATCH 4/6] ci: route PR admission through reserved AWS capacity (#16042) ## Thinking Path > - Paperclip uses CI to verify changes before release. > - Trusted PR tests can run on isolated AWS workers. > - Their runner-selection job currently waits for GitHub-hosted capacity. > - A large hosted test queue therefore blocks admission to AWS. > - A dedicated routing workflow lets us reserve capacity for metadata validation. > - This PR moves the routing job to reserved AWS capacity after the bootstrap workflow and pool are available. ## Linked Issues or Issue Description **Issue type** CI reliability enhancement. **Where is the issue?** The Select trusted runner job in PR CI. **What's wrong?** The routing job competes with a large hosted test queue. Its sampled median wait was about 20 minutes. AWS tests cannot begin before this job finishes. **Suggested fix** Add a protected reusable routing workflow. Deploy its dedicated AWS pool, then switch the PR caller in a follow-up PR. No overlapping open PR or issue was found. ## What Changed - Call ci-routing.yml on protected master from pr-trusted.yml. - Check the canonical repository and approved PR author/sender before allocating reserved AWS metadata capacity. Unapproved PRs continue to use hosted routing. - Permit approved manual routing canaries on protected master. - Use a validated roster snapshot for placement. Invalid JSON, missing settings, stale snapshots, and unapproved identities select hosted routing without a placement-time JSON parse. - Preserve the full in-job identity, rerun actor, repository, head, merge, and stack checks. - Update stack-scope and placement regression tests and document the pool and rollback. ## Verification - The routing scripts and placement expression tests passed (44 tests), routing-plus-shard tests passed (56 tests), and the existing .github/scripts suite passed (381 tests). - Existing routing allow/deny scenarios passed with the extracted script (16 cases). - actionlint passed with the dedicated custom runner label declared in its local configuration. - git diff --check passed. - Full pnpm typecheck/test/build cannot run in this worktree because dependencies are not installed. The change is limited to workflow YAML and Node tests. - The dedicated pool is deployed with two warm On-Demand workers and a four-worker ceiling. The routing qualification passed: it started about five seconds after workflow creation, selected AWS, and finished while the hosted gate in the same PR remained queued. - Bootstrap https://github.com/paperclipai/paperclip/pull/16039 is merged. The deployed group now authorizes only ci-routing.yml on protected master, and its master canary passed. ## Risks - The maintainer explicitly approved a queued-CI admin bypass after current-head Greptile 5/5 and focused verification. - Activation requires the deployed routing Fleet and its group to authorize the routing workflow on protected master. Keep the bootstrap dependency explicit. - The group must authorize only ci-routing.yml on protected master and canonical repository ID 1170821064. - The routing workflow runs no checkout or PR code and has read-only permissions. The test admission checks are unchanged. ## Model Used - OpenAI GPT-6 via Codex, with reasoning, shell tools, and file editing. The exact runtime model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused routing and workflow tests; full pnpm commands are blocked by missing worktree dependencies) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] Required CI status checked; the maintainer explicitly approved admin bypass for queued CI after clean current-head review and focused verification - [x] Greptile is 5/5 on 993fbe695acb309048bdb067d4e2656867ee1149 with no open findings or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .github/scripts/tests/ci-routing.test.mjs | 59 +++++- .github/workflows/ci-routing.yml | 2 +- .github/workflows/pr-trusted.yml | 236 +--------------------- doc/DEVELOPING.md | 22 ++ scripts/__tests__/e2e-shard.test.mjs | 21 +- 5 files changed, 99 insertions(+), 241 deletions(-) diff --git a/.github/scripts/tests/ci-routing.test.mjs b/.github/scripts/tests/ci-routing.test.mjs index fedc277482a..a9703feb747 100644 --- a/.github/scripts/tests/ci-routing.test.mjs +++ b/.github/scripts/tests/ci-routing.test.mjs @@ -4,13 +4,20 @@ 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"]); - assert.match(jobs, /^ {4}runs-on: runs-on\/fleet=paperclip-ci-routing-x64\/env=public-ci$/m); + 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); }); @@ -135,3 +142,53 @@ for (const [name, stack, base, expected] of scopeCases) { }).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 index b8d2e93fa5c..5ad3e1e11e3 100644 --- a/.github/workflows/ci-routing.yml +++ b/.github/workflows/ci-routing.yml @@ -17,7 +17,7 @@ permissions: jobs: gate: name: Select trusted runner - runs-on: runs-on/fleet=paperclip-ci-routing-x64/env=public-ci + 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 }} 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/doc/DEVELOPING.md b/doc/DEVELOPING.md index 575b1430fa8..065eea917bf 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 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\) \}\}/); From 9e721264e912cb815c379f0d6bbfafcdfe6c5cc9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:20:00 -0500 Subject: [PATCH 5/6] feat: maintain Storybook through a downstream feature catalog (#16046) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Storybook supports review of its production UI and proposed designs. > - Its 223 story files have no consistent link to product capabilities or record of component and mock provenance. > - Old compositions can drift while they still look like current product examples. > - This pull request adds two maintenance skills and a catalog that consumes the existing feature map. > - The first audit preserves all stories and fixes proven fixture drift. > - Contributors can now find examples by feature and see what still needs review. ## Linked Issues or Issue Description **What existing behavior does this improve?** Storybook maintenance, feature discovery, and the accuracy of isolated UI examples. **Current behavior** Stories are already centralized under `ui/storybook/`, but their sidebar has 51 top-level groups. Production components, local prototypes, and historical designs are not consistently distinguished. The old guide uses manual coverage claims. GitHub searches found no duplicate feature-map catalog change. **Proposed behavior** Keep existing story locations and IDs. Add a downstream catalog keyed to stable feature capabilities. Record product status, rendered subject, data boundary, and declared review dependencies separately. Use dedicated maintenance and review skills to reconcile drift and report evidence. **Reason and benefit** The catalog makes existing examples reusable without treating mock behavior or old designs as production acceptance. The feature map remains independent of Storybook. ## What Changed - Add `maintain-storybook` and `review-storybook` skills with contributor guidance. - Inventory all 223 story files. Generate a feature index and provide searchable navigation in the existing Overview story. Track direct subjects through relative imports and local wrappers, including the primitives demonstrated by foundation suites. - Add a checker for inventory, capability references, duplicate IDs, and declared-source drift, with eight regression tests. The parser test covers real nested CSF files, export filters, explicit IDs, and play counts. - Preserve historical designs and classify mixed wrappers. Record the initial audit and remaining parity work. - Align the question-dismiss/send fixture with production behavior. Add dismissal and confirmation-reopen regressions and repair the editor selector. - Exclude three exported fast-response fixtures from story discovery and add the missing render-time callback spy. ## Verification - Passed: `node scripts/storybook-catalog.mjs`, `node --test scripts/storybook-catalog.test.mjs` (8 tests), and `node scripts/check-maps.mjs`. - Passed: validators for both new skills, `pnpm check:token-gates`, `pnpm -r typecheck`, and `pnpm build`. - Passed: final Storybook build; nine selected browser `play` cases; three fast-response render cases; feature-guide search/link navigation; question-fixture reset on reload. - Built-index comparison preserved all 2,208 original story IDs and added only two new regression cases. - Passed: `pnpm exec vitest run --project @paperclipai/ui` (728 files, 8,176 tests). - The full `pnpm test:run` has reported a 15-second timeout in the untouched backend case `connects advertised Workspace gmail.read without mutating definitions in authenticated`. The exact test passed standalone without changing code, expectations, or timeouts. The remaining broad run was stopped after this failure. The full suite is not verified. - Visual comparison is blocked by the existing empty baseline manifest. The managed-accounts API/DB fixture was not started. See `maps/storybook-map/REVIEW.md` for exact scope. ## Risks - This is an initial inventory and targeted audit. 188 suites still require deeper state and fixture parity review. - Review hashes cover declared dependencies. They do not follow all transitive production imports or prove semantic correctness. - The catalog requires upkeep when stories, mapped recipes, or declared subjects change. Shared mocks still need feature-by-feature contract review. - No production application source, database schema, or upstream feature-map contract changes. ## Model Used OpenAI GPT-6 via Codex. Used reasoning, shell tools, file editing, and Chromium browser verification. The exact runtime model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../create-issue-interaction-ui/SKILL.md | 2 +- .agents/skills/maintain-storybook/SKILL.md | 72 + .../maintain-storybook/agents/openai.yaml | 3 + .agents/skills/review-storybook/SKILL.md | 47 + .../review-storybook/agents/openai.yaml | 3 + AGENTS.md | 6 + doc/DEVELOPING.md | 9 + maps/storybook-map/INDEX.md | 609 ++ maps/storybook-map/README.md | 73 + maps/storybook-map/REVIEW.md | 125 + maps/storybook-map/catalog.json | 5142 +++++++++++++++++ scripts/storybook-catalog.mjs | 134 + scripts/storybook-catalog.test.mjs | 114 + ui/storybook/prototypes/README.md | 2 +- ui/storybook/stories/agent-chat.stories.tsx | 2 +- ui/storybook/stories/chat-entry.stories.tsx | 2 +- .../fast-responses/01-settings.stories.tsx | 2 + ui/storybook/stories/overview.stories.tsx | 257 +- ...hat-interaction-above-composer.stories.tsx | 33 +- 19 files changed, 6445 insertions(+), 192 deletions(-) create mode 100644 .agents/skills/maintain-storybook/SKILL.md create mode 100644 .agents/skills/maintain-storybook/agents/openai.yaml create mode 100644 .agents/skills/review-storybook/SKILL.md create mode 100644 .agents/skills/review-storybook/agents/openai.yaml create mode 100644 maps/storybook-map/INDEX.md create mode 100644 maps/storybook-map/README.md create mode 100644 maps/storybook-map/REVIEW.md create mode 100644 maps/storybook-map/catalog.json create mode 100644 scripts/storybook-catalog.mjs create mode 100644 scripts/storybook-catalog.test.mjs diff --git a/.agents/skills/create-issue-interaction-ui/SKILL.md b/.agents/skills/create-issue-interaction-ui/SKILL.md index 9cebfacc95b..db19f147680 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/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..ce4837167e8 --- /dev/null +++ b/.agents/skills/maintain-storybook/SKILL.md @@ -0,0 +1,72 @@ +--- +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. +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/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/doc/DEVELOPING.md b/doc/DEVELOPING.md index 065eea917bf..e803ac65a22 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -124,6 +124,15 @@ 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. +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 diff --git a/maps/storybook-map/INDEX.md b/maps/storybook-map/INDEX.md new file mode 100644 index 00000000000..e4ad7063ab5 --- /dev/null +++ b/maps/storybook-map/INDEX.md @@ -0,0 +1,609 @@ +# Storybook by feature + + + +[Contract and maintenance](README.md) · [Review findings](REVIEW.md) + +223 story files; 2210 indexed stories. Counts describe inventory, not passed tests or complete feature coverage. + +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 | +| --- | --- | --- | --- | --- | +| [Private tasks/00 Review guide](../../ui/storybook/stories/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. | +| [Private tasks/01 Creation](../../ui/storybook/stories/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. | +| [Private tasks/02 Sharing](../../ui/storybook/stories/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. | +| [Private tasks/03 Task actions](../../ui/storybook/stories/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. | +| [Private tasks/04 References and blockers](../../ui/storybook/stories/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. | +| [Private tasks/05 Projects](../../ui/storybook/stories/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. | +| [Private tasks/06 Full product pages](../../ui/storybook/stories/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. | +| [Private tasks/07 User stories](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Tools/Activity (audit feed)](../../ui/storybook/stories/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. | +| [Apps/App detail · Activity (lifecycle)](../../ui/storybook/stories/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. | +| [Product/Data Visualization & Misc](../../ui/storybook/stories/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. | +| [Pages/Work Timeline](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/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. | +| [Design explorations/Agent chat](../../ui/storybook/stories/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. | +| [Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/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. | +| [Design explorations/Chat entry](../../ui/storybook/stories/chat-entry.stories.tsx) | `agent-chat/discovery`, `agent-chat/history` | historical / mixed / fixtures | 9 / 3 | Retained first-created-agent sidebar/landing design predates personal primary-agent selection. Shared production conversation components remain useful. | + +## agents + +[Feature recipe](../feature-map/agents.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Pages/Agent Detail](../../ui/storybook/stories/agent-detail.stories.tsx) | `agents/lifecycle`, `agents/instructions` | unreviewed / production / fixtures | 1 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents/Persistent files](../../ui/storybook/stories/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. | +| [Product/Agent Management](../../ui/storybook/stories/agent-management.stories.tsx) | `agents/lifecycle`, `agents/instructions` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents/Personas/Full pages](../../ui/storybook/stories/agent-persona-pages.stories.tsx) | `agents/organization`, `navigation-preferences/profile` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents/Personas](../../ui/storybook/stories/agent-personas.stories.tsx) | `agents/organization`, `navigation-preferences/profile` | unreviewed / production / fixtures | 19 / 1 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Agents/Configuration refresh](../../ui/storybook/stories/agent-settings-redesign.stories.tsx) | `agents/lifecycle`, `agents/instructions` | mixed / mixed / fixtures | 15 / 0 | Production configuration panels in a locally composed settings shell; full AgentDetail route parity remains unreviewed. | +| [Product/Built-in Agents](../../ui/storybook/stories/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. | +| [Onboarding/External agent invitation/Components](../../ui/storybook/stories/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. | +| [Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/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. | +| [Onboarding/New agent](../../ui/storybook/stories/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. | +| [Primary Agent/00 Review guide](../../ui/storybook/stories/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. | +| [Primary Agent/01 Components](../../ui/storybook/stories/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. | +| [Primary Agent/02 Sidebar](../../ui/storybook/stories/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. | +| [Primary Agent/03 Agents](../../ui/storybook/stories/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. | +| [Primary Agent/04 New task](../../ui/storybook/stories/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. | +| [Primary Agent/05 Chat](../../ui/storybook/stories/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. | +| [Primary Agent/06 Journeys](../../ui/storybook/stories/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. | +| [Primary Agent/07 Confirmation](../../ui/storybook/stories/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. | +| [Summaries/SummarySlotCard](../../ui/storybook/stories/summary-slot-card.stories.tsx) | `agents/built-in` | unreviewed / production / fixtures | 15 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +## app-permissions + +[Feature recipe](../feature-map/app-permissions.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Tools & Access/ActionCard](../../ui/storybook/stories/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/Action permissions test](../../ui/storybook/stories/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/Managed accounts](../../ui/storybook/stories/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. | +| [Tools/Permitted vs Installed](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Product/Budget & Finance](../../ui/storybook/stories/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. | +| [Costs/Subscriptions](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Connections/AgentMail setup](../../ui/storybook/stories/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. | +| [Connections/AgentMail settings](../../ui/storybook/stories/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. | +| [Connections/AgentMail tasks](../../ui/storybook/stories/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. | +| [Connections/Chat connection actions](../../ui/storybook/stories/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. | +| [Connections/Chat management](../../ui/storybook/stories/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. | +| [Connectors/GitHub bot management](../../ui/storybook/stories/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. | +| [Historical/GitHub chat prototypes](../../ui/storybook/stories/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. | +| [Connections/GitHub and Code Review Bot](../../ui/storybook/stories/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. | +| [Connections/iMessage Photon](../../ui/storybook/stories/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. | +| [Connections/Slack/Automatic setup](../../ui/storybook/stories/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. | +| [Connections/Slack/Avatar in Settings](../../ui/storybook/stories/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. | +| [Connections/Slack/Communication in Settings](../../ui/storybook/stories/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. | +| [Connections/Slack/Managed setup](../../ui/storybook/stories/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. | +| [Connections/Slack/Task tools](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Decision models/01 Settings](../../ui/storybook/stories/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. | +| [Decision models/02 History](../../ui/storybook/stories/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. | +| [Decision models/03 Journey](../../ui/storybook/stories/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. | +| [Fast responses/01 Settings](../../ui/storybook/stories/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. | +| [Fast responses/02 History](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Components/Agent setup prompt](../../ui/storybook/stories/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/Canonical icon registry](../../ui/storybook/stories/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/Catalog source selection](../../ui/storybook/stories/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/Connect discoverability (PAP-11091)](../../ui/storybook/stories/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/Connect name at create time (PAP-11283)](../../ui/storybook/stories/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/Connections/Arcade](../../ui/storybook/stories/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. | +| [Connections/Browser Use Cloud/Settings](../../ui/storybook/stories/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/Connections/Composio](../../ui/storybook/stories/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. | +| [Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-instructions.stories.tsx) | `connection-setup/credentials`, `agents/instructions` | unreviewed / production / fixtures | 20 / 5 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps/Connections/Provider choice](../../ui/storybook/stories/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/Enterpret connector](../../ui/storybook/stories/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/Connections/Executor](../../ui/storybook/stories/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. | +| [PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/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. | +| [Connections/In-task connections](../../ui/storybook/stories/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/Managed Cloud OAuth handoff](../../ui/storybook/stories/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/Connections/Memory](../../ui/storybook/stories/memory-connectors.stories.tsx) | `connection-setup/credentials`, `agents/instructions` | unreviewed / production / fixtures | 21 / 7 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/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. | +| [Reviews/PAP-17835 Personal connection identity](../../ui/storybook/stories/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/Connections/Zapier](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [UX Labs/Converted Test Pages](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Explorations/Artifact cards/Commit](../../ui/storybook/stories/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. | +| [Explorations/Artifact cards/Data](../../ui/storybook/stories/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. | +| [Explorations/Artifact cards/Document](../../ui/storybook/stories/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. | +| [Explorations/Artifact cards/File](../../ui/storybook/stories/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. | +| [Explorations/Artifact cards/Image](../../ui/storybook/stories/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. | +| [Explorations/Artifact cards/Link preview](../../ui/storybook/stories/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. | +| [Explorations/Artifact cards/Pull request](../../ui/storybook/stories/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. | +| [Explorations/Artifact cards/Video](../../ui/storybook/stories/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. | +| [Artifact upload/01 Artifacts pane](../../ui/storybook/stories/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. | +| [Artifact upload/02 Paused task journey](../../ui/storybook/stories/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. | +| [Pages/Artifacts](../../ui/storybook/stories/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. | +| [Components/CSV File Tab](../../ui/storybook/stories/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. | +| [Product/Documents/Annotations](../../ui/storybook/stories/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. | +| [Product/Document Comments](../../ui/storybook/stories/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. | +| [Components/File Viewer Sheet](../../ui/storybook/stories/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. | +| [HTML artifacts/01 Preview](../../ui/storybook/stories/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. | +| [HTML artifacts/02 Workspace](../../ui/storybook/stories/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. | +| [HTML artifacts/03 Task journey](../../ui/storybook/stories/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. | +| [Task Chat/Rich Work Product Cards](../../ui/storybook/stories/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/Artifact Gallery](../../ui/storybook/stories/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/Text file tabs](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Components/Forms & Editors](../../ui/storybook/stories/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.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.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. | +| [Foundations/Primitive Coverage](../../ui/storybook/stories/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/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. | + +## gateways-profiles + +[Feature recipe](../feature-map/gateways-profiles.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Tools/Access profiles](../../ui/storybook/stories/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. | +| [Tools/Gateways (PAP-11182)](../../ui/storybook/stories/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. | +| [Assistant connections/Manage connections](../../ui/storybook/stories/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. | +| [Assistant connections/Consent](../../ui/storybook/stories/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. | +| [Assistant connections/Device authorization](../../ui/storybook/stories/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. | +| [Assistant connections/Start here](../../ui/storybook/stories/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. | +| [Assistant connections/Experimental setting](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Product/Inbox/Blocked tab](../../ui/storybook/stories/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. | +| [Decisions/DecisionCard](../../ui/storybook/stories/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. | +| [Pages/Decisions Desk](../../ui/storybook/stories/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. | +| [Product/Search & Command K](../../ui/storybook/stories/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. | +| [Pages/Decisions](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Product/Dev Ops Surfaces](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Announcements/AnnouncementCard](../../ui/storybook/stories/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. | +| [Prototypes/Task detail/Mobile tabs](../../ui/storybook/stories/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. | +| [Product/Navigation & Layout](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Onboarding/Agent arc](../../ui/storybook/stories/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. | +| [Onboarding/Character](../../ui/storybook/stories/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. | +| [Onboarding/Connect a model](../../ui/storybook/stories/onboarding-connect-model.stories.tsx) | `onboarding/adapter`, `onboarding/first-task` | historical / prototype / fixtures | 5 / 0 | Older tile/mode preview; onboarding-agent-arc mounts the production wizard. | + +## projects + +[Feature recipe](../feature-map/projects.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Proposals/Project repos/New project](../../ui/storybook/stories/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. | +| [Workspaces/Project runtime provision command](../../ui/storybook/stories/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. | +| [Proposals/Project repos/Configuration](../../ui/storybook/stories/project-repos-configuration.stories.tsx) | `projects/repositories` | proposed / mixed / fixtures | 12 / 0 | Production properties plus local configuration page composition; repository interaction is simulated. | +| [Product/Projects Goals Workspaces](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/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. | +| [Composer/Question flow](../../ui/storybook/stories/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. | +| [Chat & Comments/Connection Reviews](../../ui/storybook/stories/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. | +| [Issue Detail/Plan Decompositions](../../ui/storybook/stories/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. | +| [Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/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. | +| [Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/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. | +| [Composer/Interaction above composer](../../ui/storybook/stories/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. | +| [Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [App/Connection recovery](../../ui/storybook/stories/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. | +| [Task chat/Recovery notice](../../ui/storybook/stories/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. | +| [Tasks/Execution recovery](../../ui/storybook/stories/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. | +| [Product/Issue/Blocked notice](../../ui/storybook/stories/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. | +| [Tasks/Model capacity retry](../../ui/storybook/stories/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. | +| [Design previews/Recovery notice](../../ui/storybook/stories/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. | +| [Product/Issue Scheduled retry surfaces](../../ui/storybook/stories/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. | +| [Paperclip/Source Issue Recovery](../../ui/storybook/stories/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. | +| [Paperclip/Source-resolved Fold](../../ui/storybook/stories/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. | +| [Workspace branch recovery/01 Repair card](../../ui/storybook/stories/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. | +| [Workspace branch recovery/02 Task journey](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Product/Routines · Detail (variation C)](../../ui/storybook/stories/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. | +| [Product/Routines · Secrets tab](../../ui/storybook/stories/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. | +| [Product/Routines/Webhooks UX preview](../../ui/storybook/stories/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. | +| [Product/Routines/Webhooks](../../ui/storybook/stories/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. | +| [Product/Routines · List (grouped cards)](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Onboarding/Saved connections](../../ui/storybook/stories/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. | +| [AI Connections/Usage limits](../../ui/storybook/stories/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. | +| [AI Connections/Review](../../ui/storybook/stories/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. | +| [Connectors/Pool host](../../ui/storybook/stories/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. | +| [AI Connections/Connection pools](../../ui/storybook/stories/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. | +| [Assistant connections/Dot Runner](../../ui/storybook/stories/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. | +| [AI Connections/Provider routing/07 Subscription sign-in](../../ui/storybook/stories/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. | +| [AI Connections/Provider routing/00 Overview](../../ui/storybook/stories/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. | +| [AI Connections/Provider routing/01 Onboarding](../../ui/storybook/stories/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. | +| [AI Connections/Provider routing/02 Connect](../../ui/storybook/stories/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. | +| [AI Connections/Provider routing/03 Agent](../../ui/storybook/stories/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. | +| [AI Connections/Provider routing/04 Manage](../../ui/storybook/stories/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. | +| [AI Connections/Provider routing/05 Recovery](../../ui/storybook/stories/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. | +| [AI Connections/Provider routing/06 Production components](../../ui/storybook/stories/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. | +| [Composer/Session goals](../../ui/storybook/stories/session-goals.stories.tsx) | `runs-adapters/session` | unreviewed / production / fixtures | 3 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | + +## secrets + +[Feature recipe](../feature-map/secrets.md) + +| Story suite | Capabilities | Product / implementation / data | Stories / play functions | Review note | +| --- | --- | --- | --- | --- | +| [Task credential access/01 Notice](../../ui/storybook/stories/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. | +| [Task credential access/02 Task page](../../ui/storybook/stories/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. | +| [Product/Environment Variables Editor](../../ui/storybook/stories/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. | +| [Product/Secrets](../../ui/storybook/stories/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. | +| [Product/User secrets](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Agent Skills/AgentSkillRow](../../ui/storybook/stories/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. | +| [QA / Agent skills edge states](../../ui/storybook/stories/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. | +| [Skill Studio/AgentsUsingSkill](../../ui/storybook/stories/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. | +| [Skill Studio/EditACopy](../../ui/storybook/stories/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. | +| [Product/Skill Studio · FrontmatterPanel](../../ui/storybook/stories/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. | +| [Skills/GitHub sync journey](../../ui/storybook/stories/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. | +| [Skills/GitHub synced skills](../../ui/storybook/stories/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. | +| [Skills/Repository packages](../../ui/storybook/stories/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. | +| [Skills/Import progress](../../ui/storybook/stories/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. | +| [Product/Skills/Policy surfaces](../../ui/storybook/stories/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. | +| [Skills Store/Skill detail](../../ui/storybook/stories/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. | +| [Skills Store/Discovery grid](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Composer/Steering and queued messages](../../ui/storybook/stories/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. | +| [Surfaces/Interrupt Handoff](../../ui/storybook/stories/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. | +| [Composer/Paused task takeover](../../ui/storybook/stories/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. | +| [Composer/Execution controls](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Product/Decisions/Stalled review actions](../../ui/storybook/stories/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. | +| [Product/Issue Monitor surfaces](../../ui/storybook/stories/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. | +| [UX Labs/Tasks Created From a Task](../../ui/storybook/stories/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. | +| [UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/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. | +| [Product/Task Watchdog surfaces](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/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. | +| [Product/Chat & Comments](../../ui/storybook/stories/chat-comments.stories.tsx) | `tasks/discussion`, `runs-adapters/runs` | unreviewed / production / fixtures | 6 / 0 | Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. | +| [Tasks/Completed activity preview](../../ui/storybook/stories/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. | +| [Composer/Add menu](../../ui/storybook/stories/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. | +| [Composer/Mobile assignee picker](../../ui/storybook/stories/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. | +| [Composer/Model and effort picker](../../ui/storybook/stories/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. | +| [Product/Control Plane Surfaces](../../ui/storybook/stories/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. | +| [Product/Dialogs & Modals](../../ui/storybook/stories/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. | +| [Foundations/External Objects](../../ui/storybook/stories/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. | +| [Product/Issue Management](../../ui/storybook/stories/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. | +| [Components/Entity pickers/Mobile](../../ui/storybook/stories/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. | +| [Composer/New task](../../ui/storybook/stories/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/Runner activity preview](../../ui/storybook/stories/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. | +| [Paperclip/Successful Run Handoff](../../ui/storybook/stories/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/Task chat review fixes](../../ui/storybook/stories/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. | +| [Task chat/Mobile streaming follow](../../ui/storybook/stories/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. | +| [Product/Task chat/System notice](../../ui/storybook/stories/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/Context notices](../../ui/storybook/stories/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. | +| [Chat & Comments/Human identity/00 Review guide](../../ui/storybook/stories/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. | +| [Chat & Comments/Human identity/01 Components](../../ui/storybook/stories/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. | +| [Chat & Comments/Human identity/02 Mixed-author journey](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Surfaces/Team Catalog](../../ui/storybook/stories/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 | +| --- | --- | --- | --- | --- | +| [Tasks/Browser Use/Task activity](../../ui/storybook/stories/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. | +| [Tasks/Browser Use/Footer](../../ui/storybook/stories/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. | +| [Tasks/Browser Use/Panel](../../ui/storybook/stories/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. | +| [Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/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. | +| [Issue/Workspace File Browser](../../ui/storybook/stories/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. | +| [Workspaces/Runtime provisioning status](../../ui/storybook/stories/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. | +| [Workspaces/Service control bar](../../ui/storybook/stories/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`: [Onboarding/External agent invitation/Components](../../ui/storybook/stories/external-agent-invite/components.stories.tsx) (unreviewed), [Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/external-agent-invite/journeys.stories.tsx) (unreviewed) +- `access/membership`: No cataloged story. +- `access/resources`: [Private tasks/00 Review guide](../../ui/storybook/stories/private-tasks/00-review-guide.stories.tsx) (unreviewed), [Private tasks/01 Creation](../../ui/storybook/stories/private-tasks/01-creation.stories.tsx) (unreviewed), [Private tasks/02 Sharing](../../ui/storybook/stories/private-tasks/02-sharing.stories.tsx) (unreviewed), [Private tasks/03 Task actions](../../ui/storybook/stories/private-tasks/03-task-actions.stories.tsx) (unreviewed), [Private tasks/04 References and blockers](../../ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx) (unreviewed), [Private tasks/05 Projects](../../ui/storybook/stories/private-tasks/05-projects.stories.tsx) (unreviewed), [Private tasks/06 Full product pages](../../ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx) (unreviewed), [Private tasks/07 User stories](../../ui/storybook/stories/private-tasks/07-user-stories.stories.tsx) (unreviewed) +- `access/cli-auth`: No cataloged story. +- `activity/overview`: [Product/Data Visualization & Misc](../../ui/storybook/stories/data-viz-misc.stories.tsx) (unreviewed) +- `activity/activity`: [Tools/Activity (audit feed)](../../ui/storybook/stories/activity-feed.stories.tsx) (unreviewed), [Apps/App detail · Activity (lifecycle)](../../ui/storybook/stories/app-activity-lifecycle.stories.tsx) (unreviewed), [Product/Control Plane Surfaces](../../ui/storybook/stories/control-plane-surfaces.stories.tsx) (unreviewed) +- `activity/runs`: No cataloged story. +- `activity/routine-audit`: No cataloged story. +- `activity/timeline`: [Pages/Work Timeline](../../ui/storybook/stories/work-timeline.stories.tsx) (unreviewed) +- `agent-chat/discovery`: [Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat-sidebar.stories.tsx) (current), [Design explorations/Agent chat](../../ui/storybook/stories/agent-chat.stories.tsx) (mixed), [Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-conversation-sidebar.stories.tsx) (current), [Design explorations/Chat entry](../../ui/storybook/stories/chat-entry.stories.tsx) (historical), [Primary Agent/00 Review guide](../../ui/storybook/stories/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Primary Agent/01 Components](../../ui/storybook/stories/primary-agent/01-components.stories.tsx) (unreviewed), [Primary Agent/02 Sidebar](../../ui/storybook/stories/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Primary Agent/03 Agents](../../ui/storybook/stories/primary-agent/03-agents.stories.tsx) (unreviewed), [Primary Agent/04 New task](../../ui/storybook/stories/primary-agent/04-new-task.stories.tsx) (unreviewed), [Primary Agent/05 Chat](../../ui/storybook/stories/primary-agent/05-chat.stories.tsx) (unreviewed), [Primary Agent/06 Journeys](../../ui/storybook/stories/primary-agent/06-journeys.stories.tsx) (unreviewed), [Primary Agent/07 Confirmation](../../ui/storybook/stories/primary-agent/07-confirmation.stories.tsx) (unreviewed) +- `agent-chat/history`: [Design explorations/Agent chat sidebar/Pages](../../ui/storybook/stories/agent-chat-sidebar.stories.tsx) (current), [Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/agent-chat-unanswered-questions.stories.tsx) (unreviewed), [Design explorations/Agent chat](../../ui/storybook/stories/agent-chat.stories.tsx) (mixed), [Design explorations/Agent chat sidebar/Components](../../ui/storybook/stories/agent-conversation-sidebar.stories.tsx) (current), [Design explorations/Chat entry](../../ui/storybook/stories/chat-entry.stories.tsx) (historical) +- `agent-chat/handoff`: No cataloged story. +- `agent-chat/conference-room`: No cataloged story. +- `agents/hire`: [Onboarding/Saved connections](../../ui/storybook/stories/agent-connection-reuse.stories.tsx) (current), [Product/Dialogs & Modals](../../ui/storybook/stories/dialogs-modals.stories.tsx) (unreviewed), [Onboarding/External agent invitation/Components](../../ui/storybook/stories/external-agent-invite/components.stories.tsx) (unreviewed), [Onboarding/External agent invitation/Journeys](../../ui/storybook/stories/external-agent-invite/journeys.stories.tsx) (unreviewed), [Onboarding/New agent](../../ui/storybook/stories/new-agent-onboarding.stories.tsx) (historical) +- `agents/instructions`: [Pages/Agent Detail](../../ui/storybook/stories/agent-detail.stories.tsx) (unreviewed), [Agents/Persistent files](../../ui/storybook/stories/agent-files.stories.tsx) (unreviewed), [Product/Agent Management](../../ui/storybook/stories/agent-management.stories.tsx) (unreviewed), [Agents/Configuration refresh](../../ui/storybook/stories/agent-settings-redesign.stories.tsx) (mixed), [Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-instructions.stories.tsx) (unreviewed), [Apps/Connections/Memory](../../ui/storybook/stories/memory-connectors.stories.tsx) (unreviewed) +- `agents/organization`: [Agents/Personas/Full pages](../../ui/storybook/stories/agent-persona-pages.stories.tsx) (unreviewed), [Agents/Personas](../../ui/storybook/stories/agent-personas.stories.tsx) (unreviewed), [Primary Agent/00 Review guide](../../ui/storybook/stories/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Primary Agent/01 Components](../../ui/storybook/stories/primary-agent/01-components.stories.tsx) (unreviewed), [Primary Agent/02 Sidebar](../../ui/storybook/stories/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Primary Agent/03 Agents](../../ui/storybook/stories/primary-agent/03-agents.stories.tsx) (unreviewed), [Primary Agent/04 New task](../../ui/storybook/stories/primary-agent/04-new-task.stories.tsx) (unreviewed), [Primary Agent/05 Chat](../../ui/storybook/stories/primary-agent/05-chat.stories.tsx) (unreviewed), [Primary Agent/06 Journeys](../../ui/storybook/stories/primary-agent/06-journeys.stories.tsx) (unreviewed), [Primary Agent/07 Confirmation](../../ui/storybook/stories/primary-agent/07-confirmation.stories.tsx) (unreviewed) +- `agents/lifecycle`: [Pages/Agent Detail](../../ui/storybook/stories/agent-detail.stories.tsx) (unreviewed), [Product/Agent Management](../../ui/storybook/stories/agent-management.stories.tsx) (unreviewed), [Agents/Configuration refresh](../../ui/storybook/stories/agent-settings-redesign.stories.tsx) (mixed) +- `agents/built-in`: [Product/Built-in Agents](../../ui/storybook/stories/built-in-agents.stories.tsx) (unreviewed), [Summaries/SummarySlotCard](../../ui/storybook/stories/summary-slot-card.stories.tsx) (unreviewed) +- `app-permissions/agent-access`: [Tools/Permitted vs Installed](../../ui/storybook/stories/permitted-vs-installed.stories.tsx) (unreviewed) +- `app-permissions/action-policy`: [Tools & Access/ActionCard](../../ui/storybook/stories/action-card.stories.tsx) (unreviewed), [Apps/Action permissions test](../../ui/storybook/stories/action-permissions-test.stories.tsx) (unreviewed), [Tools/Permitted vs Installed](../../ui/storybook/stories/permitted-vs-installed.stories.tsx) (unreviewed) +- `app-permissions/catalog-change`: No cataloged story. +- `app-permissions/test-action`: [Tools & Access/ActionCard](../../ui/storybook/stories/action-card.stories.tsx) (unreviewed), [Apps/Action permissions test](../../ui/storybook/stories/action-permissions-test.stories.tsx) (unreviewed) +- `app-permissions/account-maintenance`: [Apps/Managed accounts](../../ui/storybook/stories/apps-managed-accounts.stories.tsx) (current) +- `budgets-costs/cost-reporting`: [Product/Budget & Finance](../../ui/storybook/stories/budget-finance.stories.tsx) (unreviewed), [Costs/Subscriptions](../../ui/storybook/stories/costs/subscriptions.stories.tsx) (unreviewed), [Decision models/01 Settings](../../ui/storybook/stories/decision-models/01-settings.stories.tsx) (unreviewed), [Decision models/02 History](../../ui/storybook/stories/decision-models/02-history.stories.tsx) (unreviewed), [Decision models/03 Journey](../../ui/storybook/stories/decision-models/03-journey.stories.tsx) (unreviewed), [Fast responses/01 Settings](../../ui/storybook/stories/fast-responses/01-settings.stories.tsx) (unreviewed), [Fast responses/02 History](../../ui/storybook/stories/fast-responses/02-history.stories.tsx) (current) +- `budgets-costs/budgets`: [Product/Budget & Finance](../../ui/storybook/stories/budget-finance.stories.tsx) (unreviewed), [Product/Control Plane Surfaces](../../ui/storybook/stories/control-plane-surfaces.stories.tsx) (unreviewed), [Costs/Subscriptions](../../ui/storybook/stories/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`: [Connections/AgentMail setup](../../ui/storybook/stories/agentmail-onboarding.stories.tsx) (unreviewed), [Connections/Chat connection actions](../../ui/storybook/stories/chat-connection-actions.stories.tsx) (unreviewed), [Connectors/GitHub bot management](../../ui/storybook/stories/github-bot-management/management.stories.tsx) (unreviewed), [Historical/GitHub chat prototypes](../../ui/storybook/stories/github-chat.stories.tsx) (historical), [Connections/GitHub and Code Review Bot](../../ui/storybook/stories/github-connections.stories.tsx) (unreviewed), [Connections/Slack/Automatic setup](../../ui/storybook/stories/slack-automatic-setup.stories.tsx) (unreviewed), [Connections/Slack/Managed setup](../../ui/storybook/stories/slack-managed-setup.stories.tsx) (unreviewed) +- `chat-channels/identity`: [Connections/Slack/Avatar in Settings](../../ui/storybook/stories/slack-avatar-settings.stories.tsx) (current), [Connections/Slack/Communication in Settings](../../ui/storybook/stories/slack-communication-settings.stories.tsx) (current) +- `chat-channels/conversation`: [Connections/AgentMail tasks](../../ui/storybook/stories/agentmail.stories.tsx) (unreviewed), [Connectors/GitHub bot management](../../ui/storybook/stories/github-bot-management/management.stories.tsx) (unreviewed), [Historical/GitHub chat prototypes](../../ui/storybook/stories/github-chat.stories.tsx) (historical), [Connections/iMessage Photon](../../ui/storybook/stories/imessage-photon.stories.tsx) (unreviewed), [Connections/Slack/Task tools](../../ui/storybook/stories/slack-tools.stories.tsx) (unreviewed) +- `chat-channels/attachments`: No cataloged story. +- `chat-channels/lifecycle`: [Connections/AgentMail settings](../../ui/storybook/stories/agentmail-settings.stories.tsx) (unreviewed), [Connections/Chat management](../../ui/storybook/stories/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`: [Decision models/01 Settings](../../ui/storybook/stories/decision-models/01-settings.stories.tsx) (unreviewed), [Decision models/02 History](../../ui/storybook/stories/decision-models/02-history.stories.tsx) (unreviewed), [Decision models/03 Journey](../../ui/storybook/stories/decision-models/03-journey.stories.tsx) (unreviewed), [Fast responses/01 Settings](../../ui/storybook/stories/fast-responses/01-settings.stories.tsx) (unreviewed), [Fast responses/02 History](../../ui/storybook/stories/fast-responses/02-history.stories.tsx) (current) +- `companies/archive`: No cataloged story. +- `companies/portability`: No cataloged story. +- `connection-setup/catalog`: [Apps/Canonical icon registry](../../ui/storybook/stories/AppLogoRegistry.stories.tsx) (unreviewed), [Apps/Catalog source selection](../../ui/storybook/stories/apps-catalog-source-selection.stories.tsx) (proposed), [Apps/Connect discoverability (PAP-11091)](../../ui/storybook/stories/apps-connect-discoverability.stories.tsx) (unreviewed), [Connectors/Pool host](../../ui/storybook/stories/connection-pool-connector.stories.tsx) (unreviewed) +- `connection-setup/identity-and-access`: [Apps/Connections/Provider choice](../../ui/storybook/stories/ConnectionProviderChoice.stories.tsx) (unreviewed), [Apps/Connect name at create time (PAP-11283)](../../ui/storybook/stories/apps-connect-name.stories.tsx) (unreviewed), [Reviews/PAP-17835 Personal connection identity](../../ui/storybook/stories/personal-connection-identity.stories.tsx) (unreviewed) +- `connection-setup/credentials`: [Apps/Connections/Arcade](../../ui/storybook/stories/ArcadeConnection.stories.tsx) (unreviewed), [Apps/Connections/Composio](../../ui/storybook/stories/ComposioConnection.stories.tsx) (unreviewed), [Apps/Connections/Executor](../../ui/storybook/stories/ExecutorConnection.stories.tsx) (unreviewed), [Apps/Connections/Zapier](../../ui/storybook/stories/ZapierConnection.stories.tsx) (unreviewed), [Connections/Browser Use Cloud/Settings](../../ui/storybook/stories/browser-use-settings.stories.tsx) (current), [Design explorations/Connections/Agent instructions](../../ui/storybook/stories/connection-instructions.stories.tsx) (unreviewed), [Apps/Enterpret connector](../../ui/storybook/stories/enterpret-connect-flow.stories.tsx) (unreviewed), [PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/fireflies-pr.stories.tsx) (unreviewed), [Apps/Connections/Memory](../../ui/storybook/stories/memory-connectors.stories.tsx) (unreviewed), [Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/notion-connect-flow.stories.tsx) (unreviewed) +- `connection-setup/handoff`: [Apps/Managed Cloud OAuth handoff](../../ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx) (unreviewed) +- `connection-setup/draft-recovery`: No cataloged story. +- `connection-setup/task-request`: [Chat & Comments/Connection Reviews](../../ui/storybook/stories/connection-reviews.stories.tsx) (unreviewed), [Connections/In-task connections](../../ui/storybook/stories/in-feed-connections.stories.tsx) (unreviewed) +- `connection-setup/channel-setup`: [Components/Agent setup prompt](../../ui/storybook/stories/agent-setup-prompt.stories.tsx) (unreviewed), [Connections/AgentMail setup](../../ui/storybook/stories/agentmail-onboarding.stories.tsx) (unreviewed), [Connections/GitHub and Code Review Bot](../../ui/storybook/stories/github-connections.stories.tsx) (unreviewed), [Connections/Slack/Automatic setup](../../ui/storybook/stories/slack-automatic-setup.stories.tsx) (unreviewed), [Connections/Slack/Managed setup](../../ui/storybook/stories/slack-managed-setup.stories.tsx) (unreviewed) +- `connection-setup/failure`: [Apps/Connections/Arcade](../../ui/storybook/stories/ArcadeConnection.stories.tsx) (unreviewed), [Apps/Connections/Composio](../../ui/storybook/stories/ComposioConnection.stories.tsx) (unreviewed), [Apps/Connections/Executor](../../ui/storybook/stories/ExecutorConnection.stories.tsx) (unreviewed), [Apps/Connections/Zapier](../../ui/storybook/stories/ZapierConnection.stories.tsx) (unreviewed), [Apps/Enterpret connector](../../ui/storybook/stories/enterpret-connect-flow.stories.tsx) (unreviewed), [Apps/Notion MCP connect flow (PAP-16650)](../../ui/storybook/stories/notion-connect-flow.stories.tsx) (unreviewed) +- `developer-labs/design`: No cataloged story. +- `developer-labs/interaction-labs`: [UX Labs/Converted Test Pages](../../ui/storybook/stories/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`: [Product/Documents/Annotations](../../ui/storybook/stories/document-annotations.stories.tsx) (unreviewed), [Product/Document Comments](../../ui/storybook/stories/document-comments.stories.tsx) (unreviewed) +- `documents-artifacts/attachments`: [Artifact upload/01 Artifacts pane](../../ui/storybook/stories/artifact-upload/01-components.stories.tsx) (unreviewed), [Artifact upload/02 Paused task journey](../../ui/storybook/stories/artifact-upload/02-task-journey.stories.tsx) (unreviewed), [Components/CSV File Tab](../../ui/storybook/stories/csv-preview.stories.tsx) (unreviewed), [Components/File Viewer Sheet](../../ui/storybook/stories/file-viewer.stories.tsx) (unreviewed), [HTML artifacts/01 Preview](../../ui/storybook/stories/html-artifacts/01-preview.stories.tsx) (unreviewed), [HTML artifacts/02 Workspace](../../ui/storybook/stories/html-artifacts/02-workspace.stories.tsx) (unreviewed), [HTML artifacts/03 Task journey](../../ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx) (unreviewed), [Tasks/Text file tabs](../../ui/storybook/stories/task-text-files.stories.tsx) (unreviewed) +- `documents-artifacts/work-products`: [Explorations/Artifact cards/Commit](../../ui/storybook/stories/artifact-cards/commit.stories.tsx) (unreviewed), [Explorations/Artifact cards/Data](../../ui/storybook/stories/artifact-cards/data.stories.tsx) (unreviewed), [Explorations/Artifact cards/Document](../../ui/storybook/stories/artifact-cards/document.stories.tsx) (unreviewed), [Explorations/Artifact cards/File](../../ui/storybook/stories/artifact-cards/file.stories.tsx) (unreviewed), [Explorations/Artifact cards/Image](../../ui/storybook/stories/artifact-cards/image.stories.tsx) (unreviewed), [Explorations/Artifact cards/Link preview](../../ui/storybook/stories/artifact-cards/link-preview.stories.tsx) (unreviewed), [Explorations/Artifact cards/Pull request](../../ui/storybook/stories/artifact-cards/pull-request.stories.tsx) (unreviewed), [Explorations/Artifact cards/Video](../../ui/storybook/stories/artifact-cards/video.stories.tsx) (unreviewed), [Pages/Artifacts](../../ui/storybook/stories/artifacts.stories.tsx) (unreviewed), [Task Chat/Rich Work Product Cards](../../ui/storybook/stories/rich-work-product-cards.stories.tsx) (unreviewed), [Tasks/Artifact Gallery](../../ui/storybook/stories/task-artifact-gallery.stories.tsx) (unreviewed) +- `documents-artifacts/library`: [Pages/Artifacts](../../ui/storybook/stories/artifacts.stories.tsx) (unreviewed), [Tasks/Artifact Gallery](../../ui/storybook/stories/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`: [Tools/Gateways (PAP-11182)](../../ui/storybook/stories/gateways-tab.stories.tsx) (unreviewed) +- `gateways-profiles/profiles`: [Tools/Access profiles](../../ui/storybook/stories/access-profiles.stories.tsx) (unreviewed) +- `gateways-profiles/client-connect`: [Components/Agent setup prompt](../../ui/storybook/stories/agent-setup-prompt.stories.tsx) (unreviewed), [Assistant connections/Dot Runner](../../ui/storybook/stories/dot-runner-connection.stories.tsx) (unreviewed), [Assistant connections/Manage connections](../../ui/storybook/stories/public-mcp-connections.stories.tsx) (current), [Assistant connections/Consent](../../ui/storybook/stories/public-mcp-consent.stories.tsx) (current), [Assistant connections/Device authorization](../../ui/storybook/stories/public-mcp-device.stories.tsx) (current), [Assistant connections/Start here](../../ui/storybook/stories/public-mcp-journey.stories.tsx) (unreviewed), [Assistant connections/Experimental setting](../../ui/storybook/stories/public-mcp-setting.stories.tsx) (unreviewed) +- `gateways-profiles/tokens`: No cataloged story. +- `gateways-profiles/activity`: [Tools/Activity (audit feed)](../../ui/storybook/stories/activity-feed.stories.tsx) (unreviewed), [Apps/App detail · Activity (lifecycle)](../../ui/storybook/stories/app-activity-lifecycle.stories.tsx) (unreviewed) +- `goals/hierarchy`: [Product/Dialogs & Modals](../../ui/storybook/stories/dialogs-modals.stories.tsx) (unreviewed), [Product/Projects Goals Workspaces](../../ui/storybook/stories/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`: [Product/Inbox/Blocked tab](../../ui/storybook/stories/blocked-inbox.stories.tsx) (unreviewed) +- `inbox-search/decisions`: [Decisions/DecisionCard](../../ui/storybook/stories/decision-card.stories.tsx) (unreviewed), [Pages/Decisions Desk](../../ui/storybook/stories/decisions-desk.stories.tsx) (unreviewed), [Pages/Decisions](../../ui/storybook/stories/what-needs-me.stories.tsx) (unreviewed) +- `inbox-search/search`: [Product/Search & Command K](../../ui/storybook/stories/search.stories.tsx) (unreviewed) +- `instance-operations/install-update`: No cataloged story. +- `instance-operations/server-service`: [Product/Dev Ops Surfaces](../../ui/storybook/stories/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`: [Prototypes/Task detail/Mobile tabs](../../ui/storybook/stories/mobile-task-tabs.stories.tsx) (proposed), [Product/Navigation & Layout](../../ui/storybook/stories/navigation-layout.stories.tsx) (unreviewed) +- `navigation-preferences/preferences`: No cataloged story. +- `navigation-preferences/profile`: [Agents/Personas/Full pages](../../ui/storybook/stories/agent-persona-pages.stories.tsx) (unreviewed), [Agents/Personas](../../ui/storybook/stories/agent-personas.stories.tsx) (unreviewed) +- `navigation-preferences/announcements`: [Announcements/AnnouncementCard](../../ui/storybook/stories/announcement-card.stories.tsx) (unreviewed) +- `navigation-preferences/fallbacks`: No cataloged story. +- `onboarding/instance`: No cataloged story. +- `onboarding/company`: [Onboarding/Character](../../ui/storybook/stories/onboarding-character.stories.tsx) (unreviewed) +- `onboarding/adapter`: [Onboarding/Agent arc](../../ui/storybook/stories/onboarding-agent-arc.stories.tsx) (unreviewed), [Onboarding/Connect a model](../../ui/storybook/stories/onboarding-connect-model.stories.tsx) (historical) +- `onboarding/first-task`: [Onboarding/Agent arc](../../ui/storybook/stories/onboarding-agent-arc.stories.tsx) (unreviewed), [Onboarding/Connect a model](../../ui/storybook/stories/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`: [Product/Dialogs & Modals](../../ui/storybook/stories/dialogs-modals.stories.tsx) (unreviewed), [Private tasks/05 Projects](../../ui/storybook/stories/private-tasks/05-projects.stories.tsx) (unreviewed), [Private tasks/06 Full product pages](../../ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx) (unreviewed), [Product/Projects Goals Workspaces](../../ui/storybook/stories/projects-goals-workspaces.stories.tsx) (unreviewed) +- `projects/task-context`: [UX Labs/Tasks Created From a Task](../../ui/storybook/stories/originating-tasks.stories.tsx) (unreviewed) +- `projects/repositories`: [Proposals/Project repos/New project](../../ui/storybook/stories/new-project-repos.stories.tsx) (proposed), [Proposals/Project repos/Configuration](../../ui/storybook/stories/project-repos-configuration.stories.tsx) (proposed) +- `projects/defaults`: [Workspaces/Project runtime provision command](../../ui/storybook/stories/project-execution-workspace-strategy.stories.tsx) (unreviewed) +- `questions-and-approvals/questions`: [Composer/Question flow](../../ui/storybook/stories/composer-question-flow.stories.tsx) (unreviewed), [Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/issue-thread-interactions.stories.tsx) (unreviewed), [Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/rich-acp-interactions.stories.tsx) (unreviewed), [Composer/Interaction above composer](../../ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx) (current) +- `questions-and-approvals/dismiss-and-reopen`: [Chat & Comments/Agent Chat Unanswered Questions](../../ui/storybook/stories/agent-chat-unanswered-questions.stories.tsx) (unreviewed), [Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/task-chat-unanswered-questions.stories.tsx) (unreviewed) +- `questions-and-approvals/confirmations`: [Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/issue-thread-interactions.stories.tsx) (unreviewed), [Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/rich-acp-interactions.stories.tsx) (unreviewed), [Composer/Interaction above composer](../../ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx) (current) +- `questions-and-approvals/plan-review`: [Issue Detail/Plan Decompositions](../../ui/storybook/stories/issue-plan-decompositions.stories.tsx) (unreviewed) +- `questions-and-approvals/audience`: [Chat & Comments/Issue Thread Interactions](../../ui/storybook/stories/issue-thread-interactions.stories.tsx) (unreviewed), [Chat & Comments/Rich ACP interactions](../../ui/storybook/stories/rich-acp-interactions.stories.tsx) (unreviewed), [Composer/Interaction above composer](../../ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx) (current) +- `questions-and-approvals/governance`: [Product/Control Plane Surfaces](../../ui/storybook/stories/control-plane-surfaces.stories.tsx) (unreviewed) +- `questions-and-approvals/tool-review`: [Chat & Comments/Connection Reviews](../../ui/storybook/stories/connection-reviews.stories.tsx) (unreviewed) +- `questions-and-approvals/continuation`: No cataloged story. +- `recovery/diagnosis`: [Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/assigned-backlog-safeguards.stories.tsx) (unreviewed), [Product/Inbox/Blocked tab](../../ui/storybook/stories/blocked-inbox.stories.tsx) (unreviewed), [Task credential access/01 Notice](../../ui/storybook/stories/credential-access/01-notice.stories.tsx) (unreviewed), [Task credential access/02 Task page](../../ui/storybook/stories/credential-access/02-task-page.stories.tsx) (unreviewed), [Task chat/Recovery notice](../../ui/storybook/stories/execution-blocker-notice.stories.tsx) (unreviewed), [Tasks/Execution recovery](../../ui/storybook/stories/execution-recovery.stories.tsx) (unreviewed), [Product/Issue/Blocked notice](../../ui/storybook/stories/issue-blocked-notice.stories.tsx) (unreviewed), [Design previews/Recovery notice](../../ui/storybook/stories/recovery-notice-preview.stories.tsx) (current), [Paperclip/Source Issue Recovery](../../ui/storybook/stories/source-issue-recovery.stories.tsx) (unreviewed), [Paperclip/Source-resolved Fold](../../ui/storybook/stories/source-resolved-fold.stories.tsx) (unreviewed), [Paperclip/Successful Run Handoff](../../ui/storybook/stories/successful-run-handoff.stories.tsx) (unreviewed) +- `recovery/bounded-continuation`: [Tasks/Model capacity retry](../../ui/storybook/stories/model-capacity-retry.stories.tsx) (unreviewed), [Product/Issue Scheduled retry surfaces](../../ui/storybook/stories/scheduled-retry.stories.tsx) (unreviewed) +- `recovery/source-action`: [Design previews/Recovery notice](../../ui/storybook/stories/recovery-notice-preview.stories.tsx) (current), [Paperclip/Source Issue Recovery](../../ui/storybook/stories/source-issue-recovery.stories.tsx) (unreviewed), [Paperclip/Source-resolved Fold](../../ui/storybook/stories/source-resolved-fold.stories.tsx) (unreviewed) +- `recovery/exhaustion`: No cataloged story. +- `recovery/workspace`: [Workspace branch recovery/01 Repair card](../../ui/storybook/stories/workspace-branch-recovery/01-component.stories.tsx) (unreviewed), [Workspace branch recovery/02 Task journey](../../ui/storybook/stories/workspace-branch-recovery/02-task-journey.stories.tsx) (unreviewed) +- `recovery/unknown-outcome`: No cataloged story. +- `recovery/user-continuation`: No cataloged story. +- `recovery/reconnect`: [App/Connection recovery](../../ui/storybook/stories/cloud-access-recovery.stories.tsx) (unreviewed) +- `routines/definition`: [Product/Routines · Detail (variation C)](../../ui/storybook/stories/routine-detail-c.stories.tsx) (unreviewed), [Product/Routines · Secrets tab](../../ui/storybook/stories/routine-secrets.stories.tsx) (unreviewed), [Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines-list-groups.stories.tsx) (unreviewed) +- `routines/triggers`: [PR reviews/Fireflies and app webhooks](../../ui/storybook/stories/fireflies-pr.stories.tsx) (unreviewed), [Product/Routines/Webhooks UX preview](../../ui/storybook/stories/routine-webhooks-ux.stories.tsx) (unreviewed), [Product/Routines/Webhooks](../../ui/storybook/stories/routine-webhooks.stories.tsx) (unreviewed) +- `routines/run-now`: No cataloged story. +- `routines/history`: No cataloged story. +- `routines/management`: [Product/Routines · Detail (variation C)](../../ui/storybook/stories/routine-detail-c.stories.tsx) (unreviewed), [Product/Routines · List (grouped cards)](../../ui/storybook/stories/routines-list-groups.stories.tsx) (unreviewed) +- `runs-adapters/availability`: [Composer/Mobile assignee picker](../../ui/storybook/stories/composer-mobile-assignee.stories.tsx) (unreviewed), [Composer/Model and effort picker](../../ui/storybook/stories/composer-model-picker.stories.tsx) (mixed) +- `runs-adapters/validation`: [Onboarding/Saved connections](../../ui/storybook/stories/agent-connection-reuse.stories.tsx) (current), [AI Connections/Usage limits](../../ui/storybook/stories/ai-connection-usage.stories.tsx) (unreviewed), [AI Connections/Review](../../ui/storybook/stories/ai-connections.stories.tsx) (unreviewed), [Connectors/Pool host](../../ui/storybook/stories/connection-pool-connector.stories.tsx) (unreviewed), [AI Connections/Connection pools](../../ui/storybook/stories/connection-pools.stories.tsx) (unreviewed), [Assistant connections/Dot Runner](../../ui/storybook/stories/dot-runner-connection.stories.tsx) (unreviewed), [AI Connections/Provider routing/07 Subscription sign-in](../../ui/storybook/stories/local-ai-subscription.stories.tsx) (unreviewed), [AI Connections/Provider routing/00 Overview](../../ui/storybook/stories/provider-routing-00-overview.stories.tsx) (unreviewed), [AI Connections/Provider routing/01 Onboarding](../../ui/storybook/stories/provider-routing-01-onboarding.stories.tsx) (mixed), [AI Connections/Provider routing/02 Connect](../../ui/storybook/stories/provider-routing-02-connect.stories.tsx) (mixed), [AI Connections/Provider routing/03 Agent](../../ui/storybook/stories/provider-routing-03-agent.stories.tsx) (mixed), [AI Connections/Provider routing/04 Manage](../../ui/storybook/stories/provider-routing-04-manage.stories.tsx) (mixed), [AI Connections/Provider routing/05 Recovery](../../ui/storybook/stories/provider-routing-05-recovery.stories.tsx) (mixed), [AI Connections/Provider routing/06 Production components](../../ui/storybook/stories/provider-routing-06-production.stories.tsx) (unreviewed) +- `runs-adapters/runs`: [Product/Chat & Comments](../../ui/storybook/stories/chat-comments.stories.tsx) (unreviewed), [Tasks/Completed activity preview](../../ui/storybook/stories/completed-activity.stories.tsx) (current), [Tasks/Runner activity preview](../../ui/storybook/stories/runner-activity.stories.tsx) (current), [Tasks/Task chat review fixes](../../ui/storybook/stories/task-chat-chain-of-thought.stories.tsx) (unreviewed) +- `runs-adapters/session`: [Composer/Session goals](../../ui/storybook/stories/session-goals.stories.tsx) (unreviewed) +- `secrets/company-secrets`: [Product/Secrets](../../ui/storybook/stories/secrets.stories.tsx) (unreviewed) +- `secrets/user-secrets`: [Product/User secrets](../../ui/storybook/stories/user-secrets.stories.tsx) (unreviewed) +- `secrets/proposals`: No cataloged story. +- `secrets/vault-import`: [Product/Secrets](../../ui/storybook/stories/secrets.stories.tsx) (unreviewed) +- `secrets/runtime-delivery`: [Task credential access/01 Notice](../../ui/storybook/stories/credential-access/01-notice.stories.tsx) (unreviewed), [Task credential access/02 Task page](../../ui/storybook/stories/credential-access/02-task-page.stories.tsx) (unreviewed), [Product/Environment Variables Editor](../../ui/storybook/stories/environment-variables-editor.stories.tsx) (unreviewed), [Product/Routines · Secrets tab](../../ui/storybook/stories/routine-secrets.stories.tsx) (unreviewed) +- `skills/discovery`: [Skills/GitHub sync journey](../../ui/storybook/stories/github-skills-journey.stories.tsx) (unreviewed), [Skills/GitHub synced skills](../../ui/storybook/stories/github-synced-skills.stories.tsx) (unreviewed), [Skills/Repository packages](../../ui/storybook/stories/repository-skill-packages/repository-skill-packages.stories.tsx) (unreviewed), [Skills/Import progress](../../ui/storybook/stories/skill-import-progress.stories.tsx) (unreviewed), [Skills Store/Skill detail](../../ui/storybook/stories/skills-store-detail.stories.tsx) (unreviewed), [Skills Store/Discovery grid](../../ui/storybook/stories/skills-store-discovery.stories.tsx) (unreviewed) +- `skills/authoring`: [Skill Studio/EditACopy](../../ui/storybook/stories/fork-skill-flow.stories.tsx) (unreviewed), [Product/Skill Studio · FrontmatterPanel](../../ui/storybook/stories/frontmatter-panel.stories.tsx) (unreviewed) +- `skills/versions`: [Skills/GitHub sync journey](../../ui/storybook/stories/github-skills-journey.stories.tsx) (unreviewed), [Skills/GitHub synced skills](../../ui/storybook/stories/github-synced-skills.stories.tsx) (unreviewed), [Skills/Repository packages](../../ui/storybook/stories/repository-skill-packages/repository-skill-packages.stories.tsx) (unreviewed), [Skills/Import progress](../../ui/storybook/stories/skill-import-progress.stories.tsx) (unreviewed) +- `skills/policy`: [Agent Skills/AgentSkillRow](../../ui/storybook/stories/agent-skill-row.stories.tsx) (unreviewed), [QA / Agent skills edge states](../../ui/storybook/stories/agent-skills-edge.stories.tsx) (unreviewed), [Skill Studio/AgentsUsingSkill](../../ui/storybook/stories/agents-using-skill.stories.tsx) (unreviewed), [Product/Skills/Policy surfaces](../../ui/storybook/stories/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`: [Composer/Steering and queued messages](../../ui/storybook/stories/composer-queued-messages.stories.tsx) (current) +- `steering/edit-order-discard`: [Composer/Steering and queued messages](../../ui/storybook/stories/composer-queued-messages.stories.tsx) (current) +- `steering/steer`: [Composer/Steering and queued messages](../../ui/storybook/stories/composer-queued-messages.stories.tsx) (current) +- `steering/interrupt`: [Composer/Steering and queued messages](../../ui/storybook/stories/composer-queued-messages.stories.tsx) (current), [Surfaces/Interrupt Handoff](../../ui/storybook/stories/interrupt-handoff.stories.tsx) (unreviewed) +- `steering/stop-and-pause`: [Composer/Paused task takeover](../../ui/storybook/stories/paused-composer.stories.tsx) (historical), [Composer/Execution controls](../../ui/storybook/stories/task-execution-controls.stories.tsx) (current) +- `steering/drafts`: No cataloged story. +- `steering/approval-queue`: No cataloged story. +- `task-coordination/delegation`: [Issue Detail/Plan Decompositions](../../ui/storybook/stories/issue-plan-decompositions.stories.tsx) (unreviewed), [UX Labs/Tasks Created From a Task](../../ui/storybook/stories/originating-tasks.stories.tsx) (unreviewed), [UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/sub-issues-workflow.stories.tsx) (unreviewed) +- `task-coordination/dependencies`: [Product/Issue/Blocked notice](../../ui/storybook/stories/issue-blocked-notice.stories.tsx) (unreviewed), [UX Labs/Sub-issues Workflow Checklist](../../ui/storybook/stories/sub-issues-workflow.stories.tsx) (unreviewed) +- `task-coordination/signoff`: [Product/Decisions/Stalled review actions](../../ui/storybook/stories/issue-review-panel.stories.tsx) (unreviewed) +- `task-coordination/tree-control`: [Product/Issue Monitor surfaces](../../ui/storybook/stories/monitor-surfaces.stories.tsx) (unreviewed), [Product/Task Watchdog surfaces](../../ui/storybook/stories/task-watchdog-surfaces.stories.tsx) (unreviewed) +- `tasks/create`: [Composer/Add menu](../../ui/storybook/stories/composer-add-menu.stories.tsx) (unreviewed), [Composer/Mobile assignee picker](../../ui/storybook/stories/composer-mobile-assignee.stories.tsx) (unreviewed), [Composer/Model and effort picker](../../ui/storybook/stories/composer-model-picker.stories.tsx) (mixed), [Product/Dialogs & Modals](../../ui/storybook/stories/dialogs-modals.stories.tsx) (unreviewed), [Components/Entity pickers/Mobile](../../ui/storybook/stories/mobile-entity-pickers.stories.tsx) (unreviewed), [Composer/New task](../../ui/storybook/stories/new-task-composer.stories.tsx) (current), [Primary Agent/00 Review guide](../../ui/storybook/stories/primary-agent/00-review-guide.stories.tsx) (unreviewed), [Primary Agent/01 Components](../../ui/storybook/stories/primary-agent/01-components.stories.tsx) (unreviewed), [Primary Agent/02 Sidebar](../../ui/storybook/stories/primary-agent/02-sidebar.stories.tsx) (unreviewed), [Primary Agent/03 Agents](../../ui/storybook/stories/primary-agent/03-agents.stories.tsx) (unreviewed), [Primary Agent/04 New task](../../ui/storybook/stories/primary-agent/04-new-task.stories.tsx) (unreviewed), [Primary Agent/05 Chat](../../ui/storybook/stories/primary-agent/05-chat.stories.tsx) (unreviewed), [Primary Agent/06 Journeys](../../ui/storybook/stories/primary-agent/06-journeys.stories.tsx) (unreviewed), [Primary Agent/07 Confirmation](../../ui/storybook/stories/primary-agent/07-confirmation.stories.tsx) (unreviewed) +- `tasks/organize`: [Product/Control Plane Surfaces](../../ui/storybook/stories/control-plane-surfaces.stories.tsx) (unreviewed), [Product/Issue Management](../../ui/storybook/stories/issue-management.stories.tsx) (unreviewed) +- `tasks/properties`: [Foundations/External Objects](../../ui/storybook/stories/external-objects.stories.tsx) (unreviewed), [Product/Issue Management](../../ui/storybook/stories/issue-management.stories.tsx) (unreviewed), [Private tasks/00 Review guide](../../ui/storybook/stories/private-tasks/00-review-guide.stories.tsx) (unreviewed), [Private tasks/01 Creation](../../ui/storybook/stories/private-tasks/01-creation.stories.tsx) (unreviewed), [Private tasks/02 Sharing](../../ui/storybook/stories/private-tasks/02-sharing.stories.tsx) (unreviewed), [Private tasks/03 Task actions](../../ui/storybook/stories/private-tasks/03-task-actions.stories.tsx) (unreviewed), [Private tasks/04 References and blockers](../../ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx) (unreviewed), [Private tasks/05 Projects](../../ui/storybook/stories/private-tasks/05-projects.stories.tsx) (unreviewed), [Private tasks/06 Full product pages](../../ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx) (unreviewed), [Private tasks/07 User stories](../../ui/storybook/stories/private-tasks/07-user-stories.stories.tsx) (unreviewed) +- `tasks/discussion`: [Tasks/Browser Use/Task activity](../../ui/storybook/stories/browser-use-activity.stories.tsx) (unreviewed), [Tasks/Browser Use/Footer](../../ui/storybook/stories/browser-use-footer.stories.tsx) (unreviewed), [Tasks/Browser Use/Panel](../../ui/storybook/stories/browser-use.stories.tsx) (unreviewed), [Product/Chat & Comments](../../ui/storybook/stories/chat-comments.stories.tsx) (unreviewed), [Tasks/Completed activity preview](../../ui/storybook/stories/completed-activity.stories.tsx) (current), [Tasks/Runner activity preview](../../ui/storybook/stories/runner-activity.stories.tsx) (current), [Tasks/Task chat review fixes](../../ui/storybook/stories/task-chat-chain-of-thought.stories.tsx) (unreviewed), [Task chat/Mobile streaming follow](../../ui/storybook/stories/task-chat-mobile-follow.stories.tsx) (unreviewed), [Product/Task chat/System notice](../../ui/storybook/stories/task-chat-system-notice.stories.tsx) (unreviewed), [Chat & Comments/Task Chat Unanswered Questions](../../ui/storybook/stories/task-chat-unanswered-questions.stories.tsx) (unreviewed), [Tasks/Context notices](../../ui/storybook/stories/task-context-notices.stories.tsx) (unreviewed), [Chat & Comments/Human identity/00 Review guide](../../ui/storybook/stories/thread-human-identity/00-evidence.stories.tsx) (unreviewed), [Chat & Comments/Human identity/01 Components](../../ui/storybook/stories/thread-human-identity/01-components.stories.tsx) (unreviewed), [Chat & Comments/Human identity/02 Mixed-author journey](../../ui/storybook/stories/thread-human-identity/02-task-journey.stories.tsx) (unreviewed) +- `tasks/completion`: [Paperclip/Assigned Backlog Safeguards](../../ui/storybook/stories/assigned-backlog-safeguards.stories.tsx) (unreviewed), [Paperclip/Successful Run Handoff](../../ui/storybook/stories/successful-run-handoff.stories.tsx) (unreviewed) +- `teams/discovery`: [Surfaces/Team Catalog](../../ui/storybook/stories/team-catalog.stories.tsx) (unreviewed) +- `teams/preview`: [Surfaces/Team Catalog](../../ui/storybook/stories/team-catalog.stories.tsx) (unreviewed) +- `teams/install`: No cataloged story. +- `teams/configuration`: No cataloged story. +- `workspaces/binding`: [Product/Projects Goals Workspaces](../../ui/storybook/stories/projects-goals-workspaces.stories.tsx) (unreviewed), [Components/SearchableSelect/Workspace picker](../../ui/storybook/stories/searchable-select.stories.tsx) (unreviewed) +- `workspaces/provisioning`: [Workspaces/Project runtime provision command](../../ui/storybook/stories/project-execution-workspace-strategy.stories.tsx) (unreviewed), [Workspaces/Runtime provisioning status](../../ui/storybook/stories/workspace-runtime-provisioning.stories.tsx) (unreviewed) +- `workspaces/services`: [Tasks/Browser Use/Task activity](../../ui/storybook/stories/browser-use-activity.stories.tsx) (unreviewed), [Tasks/Browser Use/Footer](../../ui/storybook/stories/browser-use-footer.stories.tsx) (unreviewed), [Tasks/Browser Use/Panel](../../ui/storybook/stories/browser-use.stories.tsx) (unreviewed), [Workspaces/Service control bar](../../ui/storybook/stories/workspace-service-control-bar.stories.tsx) (unreviewed) +- `workspaces/files-git`: [HTML artifacts/01 Preview](../../ui/storybook/stories/html-artifacts/01-preview.stories.tsx) (unreviewed), [HTML artifacts/02 Workspace](../../ui/storybook/stories/html-artifacts/02-workspace.stories.tsx) (unreviewed), [HTML artifacts/03 Task journey](../../ui/storybook/stories/html-artifacts/03-task-journey.stories.tsx) (unreviewed), [Issue/Workspace File Browser](../../ui/storybook/stories/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..528e9f57211 --- /dev/null +++ b/maps/storybook-map/README.md @@ -0,0 +1,73 @@ +# 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. + +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..a98ce00134e --- /dev/null +++ b/maps/storybook-map/REVIEW.md @@ -0,0 +1,125 @@ +# 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/`. Its 223 story +files are split between 176 files at the stories root and 47 nested files. The +existing sidebar has 51 top-level groups. A bulk file move would add churn without +fixing the main problem: product meaning, provenance, and review status were not +recorded consistently. + +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. Existing sidebar titles remain +stable to preserve links and snapshot identity. + +| 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. + +## 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. diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json new file mode 100644 index 00000000000..f5f709d681f --- /dev/null +++ b/maps/storybook-map/catalog.json @@ -0,0 +1,5142 @@ +{ + "version": 1, + "sharedReview": { + "sources": [ + "ui/storybook/.storybook/main.ts", + "ui/storybook/.storybook/preview.tsx", + "ui/storybook/fixtures/paperclipData.ts" + ], + "fingerprint": "0faabc5a8874f37674125d28314833c6a1e99c804d929a6d1e7f7917109eca0a" + }, + "entries": [ + { + "file": "ui/storybook/stories/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/AppLogoRegistry.stories.tsx" + ], + "fingerprint": "e3fa28652112529c2761778f90a577aaf9f09a13ddda6d53e55679294b48b6f7" + }, + { + "file": "ui/storybook/stories/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/ArcadeConnection.stories.tsx" + ], + "fingerprint": "af73c5883c7a3e925f793162841c310ab72e5f32f6a4a469242391480ac2c6f8" + }, + { + "file": "ui/storybook/stories/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/ComposioConnection.stories.tsx" + ], + "fingerprint": "9299acfe719df8946ca3ebc03be6dfa5d851c2b1e6df0ad805a23f082fdde8e2" + }, + { + "file": "ui/storybook/stories/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/ConnectionProviderChoice.stories.tsx" + ], + "fingerprint": "565a8e231c737785da884ecacb7f6496e47e0adcf8c0b8b53a2e783d85431190" + }, + { + "file": "ui/storybook/stories/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/ExecutorConnection.stories.tsx" + ], + "fingerprint": "28ab0d5d137cf7b0f034b38b09ab70ce149fe87a80f38925b39b4ff33ea6a1ff" + }, + { + "file": "ui/storybook/stories/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/ZapierConnection.stories.tsx" + ], + "fingerprint": "07c8ba08275396b83609fe6abe9c96a8ee3e770a79ee87d3c50dc5e154739030" + }, + { + "file": "ui/storybook/stories/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/access-profiles.stories.tsx" + ], + "fingerprint": "357db52703a6de66aa349e1be6e3b63c05d63c73ebea09b8222cc029fac61bd6" + }, + { + "file": "ui/storybook/stories/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/action-card.stories.tsx" + ], + "fingerprint": "a6435989227e6cf87f99ba2b04e2fa530916d215af99376807954b9368d841e7" + }, + { + "file": "ui/storybook/stories/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/action-permissions-test.stories.tsx" + ], + "fingerprint": "d439cc4ed01cde90656217bd21eb5252543333096f1585ed176d698073540db6" + }, + { + "file": "ui/storybook/stories/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-feed.stories.tsx" + ], + "fingerprint": "44c9670f0f93caef2fa27a2dff3068b8e8f8d5acfb04ca75605e4157e626afa2" + }, + { + "file": "ui/storybook/stories/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.", + "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-sidebar.stories.tsx" + ], + "fingerprint": "06118dfa08dfe635b484e629cd7c0f60b2659362a364586dd0769274dcf7d835" + }, + { + "file": "ui/storybook/stories/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/agent-chat-unanswered-questions.stories.tsx" + ], + "fingerprint": "f7d8eae40ddb9ce46bac841a84f56f334be3d84b4cd3a29e9e2d0be29bba210c" + }, + { + "file": "ui/storybook/stories/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.", + "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.stories.tsx" + ], + "fingerprint": "fb9e699105a458fb3d5737cb56fdbe9fe2d2d057263866a151eca5b3027706f8" + }, + { + "file": "ui/storybook/stories/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.", + "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/agent-connection-reuse.stories.tsx" + ], + "fingerprint": "47e8cbed9e9c4c8ee2c8f81a9c21c60ba9eec566927c5338e288358d3309daca" + }, + { + "file": "ui/storybook/stories/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-conversation-sidebar.stories.tsx" + ], + "fingerprint": "2487bb6ec01cd75131317b2739088256dabd217227b2af11d62590c31a9b3e14" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/pages/AgentDetail.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agent-detail.stories.tsx" + ], + "fingerprint": "2764437abfbc3c215e400ad167c45927f4a77d5aa978a2f0d267918008ffbea2" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/pages/AgentDetail.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agent-files.stories.tsx" + ], + "fingerprint": "98f8c49c2ae6b814d58948151b9d448b2ee527c8aba505682e7c4c218f66f7b3" + }, + { + "file": "ui/storybook/stories/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.", + "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/agent-config-defaults.ts", + "ui/src/components/agent-config-primitives.tsx", + "ui/storybook/fixtures/paperclipData.ts", + "ui/storybook/stories/agent-management.stories.tsx" + ], + "fingerprint": "16725cb4e0337cd38458dbc497c614de2252b564afc80648c7311c36bfa2ad11" + }, + { + "file": "ui/storybook/stories/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.", + "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/agent-persona-pages.stories.tsx" + ], + "fingerprint": "e32cbe590cef4e26e04dad1207814c21321c97b9e227b80867dd8643351e6671" + }, + { + "file": "ui/storybook/stories/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.", + "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/agent-personas.stories.tsx" + ], + "fingerprint": "03774c3aa9c051aedd0b06603e2f11b79ef2ab46f4fe36f3de3cca8b20779630" + }, + { + "file": "ui/storybook/stories/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.", + "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/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/agent-settings-redesign.stories.tsx" + ], + "fingerprint": "68fb03c2e0b52e178b3e4a31330a23de8a6568e683d7b6c1e0288fa9e8817cef" + }, + { + "file": "ui/storybook/stories/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/agent-setup-prompt.stories.tsx" + ], + "fingerprint": "fc121ecd05c89050c58aa621dc8fded8b5cf0573a1190bacca73e31c560421f9" + }, + { + "file": "ui/storybook/stories/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/agent-skill-row.stories.tsx" + ], + "fingerprint": "b7d7b0f600d8cfbc93b410283bd9ed5340c18a71e3943036d796755d6c8fd996" + }, + { + "file": "ui/storybook/stories/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/agent-skills-edge.stories.tsx" + ], + "fingerprint": "5132a7175c3065dba3d329ab7e7307f54e84834c378c3ffaa81265501d8e69e9" + }, + { + "file": "ui/storybook/stories/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/agentmail-onboarding.stories.tsx" + ], + "fingerprint": "464f6dee03ed94915bf3156bb2aa97401e180bf5db43c82bceaa6bb9a7887c66" + }, + { + "file": "ui/storybook/stories/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/agentmail-settings.stories.tsx" + ], + "fingerprint": "ba24f131f18b10d82bf6f9fac575deace7f768826c87be7802c3d13754b32a33" + }, + { + "file": "ui/storybook/stories/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/agentmail.stories.tsx" + ], + "fingerprint": "2fdb42f80a884f88df3cc3d6c2ad943a0d3795e6211419dbc3de65e87171605e" + }, + { + "file": "ui/storybook/stories/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/agents-using-skill.stories.tsx" + ], + "fingerprint": "5e5234b76008eb043c0f5eface1a5d517acd5c9bcd8eb2bafdba52dbf9d326a0" + }, + { + "file": "ui/storybook/stories/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.", + "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/ai-connection-usage.stories.tsx" + ], + "fingerprint": "4efd03cf0c2db2db7e6e0ab7355a228601f310796c503ae5bb4af1114a428173" + }, + { + "file": "ui/storybook/stories/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.", + "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/ai-connections.stories.tsx" + ], + "fingerprint": "fc716216e27fe7f4e51ce86bd85932032868845e326b7921d7d8de7e6d3694f2" + }, + { + "file": "ui/storybook/stories/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/announcement-card.stories.tsx" + ], + "fingerprint": "bd2aa00c1b5368b969623de78f908d6c150d5c7eafa7849443619577ffda5584" + }, + { + "file": "ui/storybook/stories/app-activity-lifecycle.stories.tsx", + "capabilities": [ + "activity/activity", + "gateways-profiles/activity" + ], + "purpose": "Apps/App detail \u00b7 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/app-activity-lifecycle.stories.tsx" + ], + "fingerprint": "2052136a7fe3aa332b62ca8f834466c6f4aadfac1132e4b0a339b5d90e4ae121" + }, + { + "file": "ui/storybook/stories/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/apps-catalog-source-selection.stories.tsx" + ], + "fingerprint": "71108df1e0c55a7d007d5ea9524c82fbe02c1fe09681d0f42b1cc09b682459c5" + }, + { + "file": "ui/storybook/stories/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/apps-connect-discoverability.stories.tsx" + ], + "fingerprint": "339076110e02558725fdf381a8afd22c028d6b1a2a338a4936c1fd952c4b031b" + }, + { + "file": "ui/storybook/stories/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/apps-connect-name.stories.tsx" + ], + "fingerprint": "b1a3c62891259a601e557401ae736ff07f36086d6590dd2c162dcdfbf64169ee" + }, + { + "file": "ui/storybook/stories/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/apps-managed-accounts.stories.tsx" + ], + "fingerprint": "db39db8db1587a056f564aba8644046039cf05851922e4252eba654ccef67247" + }, + { + "file": "ui/storybook/stories/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/artifact-cards/commit.stories.tsx", + "ui/storybook/stories/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "cc04cd9c903fe2481162604f9b01581f2aecbb34ffeca71b4d8f30e6a5fc4878" + }, + { + "file": "ui/storybook/stories/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/artifact-cards/data.stories.tsx", + "ui/storybook/stories/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "5d6b98f8eb2d9ff0c9731d1b693abab7af1249684e628db7e12bee50b140079e" + }, + { + "file": "ui/storybook/stories/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/artifact-cards/document.stories.tsx", + "ui/storybook/stories/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "2da653267415c871f17d8c98e9ef2fa242ba16887f4a8d1ce36f518639b86a36" + }, + { + "file": "ui/storybook/stories/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/artifact-cards/file.stories.tsx", + "ui/storybook/stories/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "ef9ef8d955bda7798faf079478bd199cfda610b4aba21b868a337708165181ea" + }, + { + "file": "ui/storybook/stories/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/artifact-cards/image.stories.tsx", + "ui/storybook/stories/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "de8efb7cdd55182eea4864acb3cc0d780316cce1f38a098c39a18146185fd8dd" + }, + { + "file": "ui/storybook/stories/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/artifact-cards/link-preview.stories.tsx", + "ui/storybook/stories/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "204dd94ac390bee7999c35f16f9018f4c9375c58c4275efa54516a4f94a997f3" + }, + { + "file": "ui/storybook/stories/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/artifact-cards/pull-request.stories.tsx", + "ui/storybook/stories/artifact-cards/storyConfig.tsx" + ], + "fingerprint": "ee061a6b23593b55d3e1faa53f29b949d4376c169154dec0d00927a191d9df0d" + }, + { + "file": "ui/storybook/stories/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/stories/artifact-cards/storyConfig.tsx", + "ui/storybook/stories/artifact-cards/video.stories.tsx" + ], + "fingerprint": "4a8f4ffd72e951ef7cb7f48d93a46b333b780d135cccbacea35112c3efd784ea" + }, + { + "file": "ui/storybook/stories/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/artifact-upload/01-components.stories.tsx", + "ui/storybook/stories/artifact-upload/UploadPreview.tsx", + "ui/storybook/stories/artifact-upload/fixtures.ts" + ], + "fingerprint": "a4a362aff84256bf6c9d668d1af32beacc213df283be72f0c15274e862d711d2" + }, + { + "file": "ui/storybook/stories/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/artifact-upload/02-task-journey.stories.tsx", + "ui/storybook/stories/artifact-upload/UploadPreview.tsx", + "ui/storybook/stories/artifact-upload/fixtures.ts" + ], + "fingerprint": "3234dc9f9f9f8554c70c5e47ce99e7625d9be6c18fde2f68e9919b67c94d189e" + }, + { + "file": "ui/storybook/stories/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/artifacts.stories.tsx" + ], + "fingerprint": "6849a85f6f4a0ace498ef44555703631ccef19d8d6c5bf0be77a0939a6355570" + }, + { + "file": "ui/storybook/stories/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/assigned-backlog-safeguards.stories.tsx" + ], + "fingerprint": "1bfdf9275f15aaf609ba074aee9ff5697640ce7aedf1f7442c29ba2efaab3815" + }, + { + "file": "ui/storybook/stories/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/blocked-inbox.stories.tsx" + ], + "fingerprint": "b3307d74bb191f0e68d92957c72583d5fbf086c8a4fd5c5a8373d9b62bf94373" + }, + { + "file": "ui/storybook/stories/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/browser-use-activity.stories.tsx" + ], + "fingerprint": "f48f9bca0111f2c8724b8e6988519a2773d50bad540384e9700c2d2223989702" + }, + { + "file": "ui/storybook/stories/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/browser-use-footer.stories.tsx" + ], + "fingerprint": "1b526f900301f1f88da2f6407aee5aca7810febbe08ee21d2ea894caa0c1fc16" + }, + { + "file": "ui/storybook/stories/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/browser-use-settings.stories.tsx" + ], + "fingerprint": "d3c1ef6ae15960f95b3f01a29015ec13f12c808b3518a09fe51f2343a6c45cf6" + }, + { + "file": "ui/storybook/stories/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/browser-use.stories.tsx" + ], + "fingerprint": "c20411591b3079cb7327011443916a51814cbe08a352e43b55a5e760fb60bbcb" + }, + { + "file": "ui/storybook/stories/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/budget-finance.stories.tsx" + ], + "fingerprint": "87b870bba22a1ce4ec0fec08bdaf04e45c5cffe47c74a3b0de4950fee8f716f3" + }, + { + "file": "ui/storybook/stories/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.", + "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/built-in-agents.stories.tsx" + ], + "fingerprint": "340d7f75d5e4fe1cf8a4da2de80993db12476da37f865095d85f8f3ad199d322" + }, + { + "file": "ui/storybook/stories/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.", + "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/chat-comments.stories.tsx" + ], + "fingerprint": "42d7f459fe99fa1ffe35aca99adc7d50fbf021def17217d4d0d98a3138147545" + }, + { + "file": "ui/storybook/stories/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-connection-actions.stories.tsx" + ], + "fingerprint": "584867de85a87346a449e621a647ad0560480f49c1ecb5856fc289a37efdd55d" + }, + { + "file": "ui/storybook/stories/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.", + "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/chat-entry.stories.tsx" + ], + "fingerprint": "bd01b59465cfd0306600927d9d866699aae96586d86b9d4dd62d44fb95b9e559", + "replacement": "ui/storybook/stories/primary-agent/05-chat.stories.tsx" + }, + { + "file": "ui/storybook/stories/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-management/chat-management.stories.tsx" + ], + "fingerprint": "35e68e70b16c8fc6eee7d7b80b39ba0a8c06ef855b25617678e37089591e63e5" + }, + { + "file": "ui/storybook/stories/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/cloud-access-recovery.stories.tsx" + ], + "fingerprint": "54a65a26d71cbfe2c513c7b88c93c5c3ce5cd34aa0930a992595a8506e6cc16c" + }, + { + "file": "ui/storybook/stories/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.", + "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/completed-activity.stories.tsx" + ], + "fingerprint": "b402003511480056c4da8c1d21ce6965ab73affe015320448221d1a8bbe4bcff" + }, + { + "file": "ui/storybook/stories/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/composer-add-menu.stories.tsx" + ], + "fingerprint": "38fa1c5109aa9cb260b1984b63fa68a0e26060c771739013281025e5f5ba1ff4" + }, + { + "file": "ui/storybook/stories/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.", + "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/composer-mobile-assignee.stories.tsx" + ], + "fingerprint": "36c893ca298a464eeefa476ac8fca984eee193610d071123bfbabeca347cd91c" + }, + { + "file": "ui/storybook/stories/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.", + "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/composer-model-picker.stories.tsx" + ], + "fingerprint": "ca7bbe93d9cc0deb70b4ba628c1cce4733ee2740606da3b580edf5f1f637064e" + }, + { + "file": "ui/storybook/stories/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/composer-question-flow.stories.tsx" + ], + "fingerprint": "77aefffb9459df532e75930249ac41364b2767c6a86180cb6f585b2b503397a6" + }, + { + "file": "ui/storybook/stories/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/composer-queued-messages.stories.tsx" + ], + "fingerprint": "552065861fb09fde815ba7ee5474d2ac8029a8cd9709e416feab8f68bde9f665" + }, + { + "file": "ui/storybook/stories/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.", + "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-instructions.stories.tsx" + ], + "fingerprint": "f893e2f93a81a7dfee9bbdb283c7fe6751816895e520620ca44ac308447a6be9" + }, + { + "file": "ui/storybook/stories/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.", + "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/connection-pool-connector.stories.tsx" + ], + "fingerprint": "bcbb6152b8d200bc36aaffafbd7b0caf3d66d4af568f3891334c1772a9d69762" + }, + { + "file": "ui/storybook/stories/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.", + "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/connection-pools.stories.tsx" + ], + "fingerprint": "a86d650543360c07057740d5cc7f3e48cbee9e290b86c96adcc18158b749e130" + }, + { + "file": "ui/storybook/stories/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/connection-reviews.stories.tsx" + ], + "fingerprint": "4781c70468820e516ec54b28c4aa86c892ebdd8ea7ff4d8beda7f45ce16cf52b" + }, + { + "file": "ui/storybook/stories/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/control-plane-surfaces.stories.tsx" + ], + "fingerprint": "48e463f33df3edee88e408325f106f057cf626392a7798db29ff21e67cc1751b" + }, + { + "file": "ui/storybook/stories/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/costs/subscriptions.stories.tsx" + ], + "fingerprint": "44c98579ca40b85cc50891025e0a162475e6f9e355106a52cff2d928123b0e7f" + }, + { + "file": "ui/storybook/stories/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/credential-access/01-notice.stories.tsx" + ], + "fingerprint": "3f39c81151efe0e2eddf5eb3b6dc8e2ebe8bba356907acf26388e7ac5803c22a" + }, + { + "file": "ui/storybook/stories/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.", + "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/credential-access/02-task-page.stories.tsx", + "ui/storybook/stories/credential-access/CredentialTaskPage.tsx" + ], + "fingerprint": "dee6d9ef6fa8157814bddba5cb3b98e3fe5701c4ae0393dfa4662217f1af3ef1" + }, + { + "file": "ui/storybook/stories/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/csv-preview.stories.tsx" + ], + "fingerprint": "8dbae007bfb9b3667c2da65148ce2e9512e8db0eb95f928d2f31de629aca2652" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/activity.md", + "ui/src/components/ActivityCharts.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/data-viz-misc.stories.tsx" + ], + "fingerprint": "ad2d828d1515d8e6143029578a298b6c7d43b28134b3f0881d0389859b815abc" + }, + { + "file": "ui/storybook/stories/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/decision-card.stories.tsx" + ], + "fingerprint": "6e0e5aab6a83de14c0bea6778c15f39420bc52086b4f3660bdcb7d07d446e5ea" + }, + { + "file": "ui/storybook/stories/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/decision-models/01-settings.stories.tsx", + "ui/storybook/stories/decision-models/fixtures.ts" + ], + "fingerprint": "f2ba9d71abb3a7c5e3fbc5a9aa4ecc9c516d6de381c3d625f5339bd28c42bfc9" + }, + { + "file": "ui/storybook/stories/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/decision-models/02-history.stories.tsx", + "ui/storybook/stories/decision-models/fixtures.ts" + ], + "fingerprint": "27b401345fcace0bb84970d98fb2f1029de3c75c4c9e509b9772fd2fa60d7ff2" + }, + { + "file": "ui/storybook/stories/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/decision-models/03-journey.stories.tsx", + "ui/storybook/stories/decision-models/fixtures.ts" + ], + "fingerprint": "588cc23bf34b758335fd5ab5ce0f69f478b638b61058a7069bc3de2da327333e" + }, + { + "file": "ui/storybook/stories/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/decisions-desk.stories.tsx" + ], + "fingerprint": "5aa325138c3bd33be22c11c805d3b0e30bdf40f6e67e5f131d45536fc1af6c52" + }, + { + "file": "ui/storybook/stories/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/dev-ops-surfaces.stories.tsx" + ], + "fingerprint": "82dde37a5fbbdbaf053c736e3146a7c2bd44da21f0d3a1f48db56149fbeb729a" + }, + { + "file": "ui/storybook/stories/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.", + "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/dialogs-modals.stories.tsx" + ], + "fingerprint": "92e635333d20802c9677c94bc00eff89a6b65fbdef225b9701e71b5919a6ddb5" + }, + { + "file": "ui/storybook/stories/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/document-annotations.stories.tsx" + ], + "fingerprint": "36a016bab626258ff76af00b5697ef622a29e19f7032d241ebedd6550ff837f8" + }, + { + "file": "ui/storybook/stories/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/document-comments.stories.tsx" + ], + "fingerprint": "7abfc302970dd3cc86ac621735b88a368baf216947b674a6b4283cdaf7f31c03" + }, + { + "file": "ui/storybook/stories/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.", + "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/dot-runner-connection.stories.tsx" + ], + "fingerprint": "5dd7b6158480717fe85fe65a7df9323fc753ec3c673cf9bed002304c3e87cbc0" + }, + { + "file": "ui/storybook/stories/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/enterpret-connect-flow.stories.tsx" + ], + "fingerprint": "d03b56c9a89770a5f4222d0567be3c98aa4c878732098c4dcf2573f54cc5bb2d" + }, + { + "file": "ui/storybook/stories/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/environment-variables-editor.stories.tsx" + ], + "fingerprint": "daf57dd13f3c746a473b4c9bd7996f0dd70c9bfef9c698d2eb581a40b6deac08" + }, + { + "file": "ui/storybook/stories/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/execution-blocker-notice.stories.tsx" + ], + "fingerprint": "99fbecc373d993e752e8a89580492eecf947a54329f067cf468f97d18cd328b6" + }, + { + "file": "ui/storybook/stories/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/execution-recovery.stories.tsx" + ], + "fingerprint": "d726c8a489acdd94563d30b8a0c8e48c965d3bd429fbe2a2463d4d6c6c065b28" + }, + { + "file": "ui/storybook/stories/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.", + "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/external-agent-invite/components.stories.tsx" + ], + "fingerprint": "456e1d9cfad730f3fc7d8c003bb4ab4296417e3869f8d7acbc360d3337f5cb58" + }, + { + "file": "ui/storybook/stories/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.", + "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/external-agent-invite/ExternalAgentInvitePreview.tsx", + "ui/storybook/stories/external-agent-invite/fixtures.ts", + "ui/storybook/stories/external-agent-invite/journeys.stories.tsx" + ], + "fingerprint": "9f79299cb00018bddaccd36024e79a5876b9543fdd772938cced8c38a3b3823c" + }, + { + "file": "ui/storybook/stories/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/external-objects.stories.tsx" + ], + "fingerprint": "2e6a73b716687244aee6f93214c025bfe755ce41a4aa6bd65a37a4d6d3275596" + }, + { + "file": "ui/storybook/stories/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/stories/decision-models/fixtures.ts", + "ui/storybook/stories/fast-responses/01-settings.stories.tsx" + ], + "fingerprint": "4cf2ba38415e1a07a1e265858961d5414ef19bc2090b4b93c4b18cb5e691310d" + }, + { + "file": "ui/storybook/stories/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/stories/decision-models/fixtures.ts", + "ui/storybook/stories/fast-responses/02-history.stories.tsx" + ], + "fingerprint": "c123c70c4cdc78548a5c1d50777f456946366cf532ac8e01ab48a625d3fbc285" + }, + { + "file": "ui/storybook/stories/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/file-viewer.stories.tsx" + ], + "fingerprint": "a3d440e82f0a0f537dbe3634770e807ca21cab8b567d2eb3ac48780b67f72790" + }, + { + "file": "ui/storybook/stories/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/fireflies-pr.stories.tsx" + ], + "fingerprint": "0dc5bda5ecbb676ba9758350884e51ebe017a0e17b24802107a1472296acae51" + }, + { + "file": "ui/storybook/stories/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/fork-skill-flow.stories.tsx" + ], + "fingerprint": "158ff9882eec7349de17fe68bfe1470a925b7f272fd8f82450b6f2100c58f6b5" + }, + { + "file": "ui/storybook/stories/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/forms-editors.stories.tsx" + ], + "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", + "fingerprint": "1e30e2c2e33d155a068ccbc1a5709d1ea5d40ad8d87fe81c9981dd19fd2fb497" + }, + { + "file": "ui/storybook/stories/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.", + "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.stories.tsx" + ], + "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", + "fingerprint": "d6d2f239b4d934521156ed03cc515de419e02a48256fc33aab9cb0fc9b57c078" + }, + { + "file": "ui/storybook/stories/frontmatter-panel.stories.tsx", + "capabilities": [ + "skills/authoring" + ], + "purpose": "Product/Skill Studio \u00b7 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/frontmatter-panel.stories.tsx" + ], + "fingerprint": "0bf948fb2bad1159d2415df2d2d50e106ef696f6a259dcf6dc4afe15c9765fe6" + }, + { + "file": "ui/storybook/stories/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-tab.stories.tsx" + ], + "fingerprint": "ae5af9816de7c7159e3a26d86cbf69ede2f9a58b43f272a03849b2e490a66016" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/chat-channels.md", + "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/github-bot-management/fixture-api.tsx", + "ui/storybook/stories/github-bot-management/fixtures.ts", + "ui/storybook/stories/github-bot-management/management.stories.tsx" + ], + "fingerprint": "b48f3a6a4aae5c83d4a8481105c6d2e80d80c694006e2f18804f1a4eed107fb5" + }, + { + "file": "ui/storybook/stories/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/github-chat.stories.tsx" + ], + "replacement": "ui/storybook/stories/github-connections.stories.tsx", + "fingerprint": "f110f2f0ccee9e5bc8eb633f2e6cac4e4aea432232cdd702fd233277380a7028" + }, + { + "file": "ui/storybook/stories/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/github-connections.stories.tsx" + ], + "fingerprint": "2932e77ea9103b6c08585123cc4ca4bcdd35ab5c9539f70e2b30bae165adc3cb" + }, + { + "file": "ui/storybook/stories/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/github-skills-journey.stories.tsx" + ], + "fingerprint": "c4d5daba6e1ced0fea63533094e2ecc25c14bdab7e078a61c1aac95184fe3bc6" + }, + { + "file": "ui/storybook/stories/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/github-synced-skills.stories.tsx" + ], + "fingerprint": "56e11ea7e42f58754b347286ac84df240314d3fc6b2d973b1df1215985d89de9" + }, + { + "file": "ui/storybook/stories/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/html-artifacts/01-preview.stories.tsx", + "ui/storybook/stories/html-artifacts/fixtures.ts" + ], + "fingerprint": "eb93ebf603a4ba1d7103e49d2a7486bdcc3fe02aa8c87c1bbbbe165fde24bd6d" + }, + { + "file": "ui/storybook/stories/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/html-artifacts/02-workspace.stories.tsx", + "ui/storybook/stories/html-artifacts/fixtures.ts" + ], + "fingerprint": "a20420d26e42635529e7b298376a27edf3ff761ed7de134c5349bd5b9934198f" + }, + { + "file": "ui/storybook/stories/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/html-artifacts/03-task-journey.stories.tsx", + "ui/storybook/stories/html-artifacts/fixtures.ts" + ], + "fingerprint": "2d4ab0475f66fea01b18ddd3a54694c7ed94fd749dc457c8f134de20b9faea78" + }, + { + "file": "ui/storybook/stories/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/imessage-photon.stories.tsx" + ], + "fingerprint": "3bd3147aaf8c0eb0df017ddd3a10c0a11f8429907ac9d24049e8b507224d7da6" + }, + { + "file": "ui/storybook/stories/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/in-feed-connections.stories.tsx" + ], + "fingerprint": "91d582c9624c38820c4d6991c3ea16c7dda10be7ea5122e8a97b63e34d4c849a" + }, + { + "file": "ui/storybook/stories/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/interrupt-handoff.stories.tsx" + ], + "fingerprint": "975a75361986c904fb485958695acbf13f100b9ce4652e8eb4c0a032e5b9c9a1" + }, + { + "file": "ui/storybook/stories/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/issue-blocked-notice.stories.tsx" + ], + "fingerprint": "439f467f261680b59ef3772406f9f6bf1fdeb68c2d820cb10e055ae5283589d6" + }, + { + "file": "ui/storybook/stories/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/issue-management.stories.tsx" + ], + "fingerprint": "a5012bece16d1825dbabc03512795d082ebcd08e55cedf04b05fcef82e0f4395" + }, + { + "file": "ui/storybook/stories/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/issue-plan-decompositions.stories.tsx" + ], + "fingerprint": "b527e5525ef4f4e3b0b834cf22e423e2c369692b887243f01cc4d6b5ebfa5875" + }, + { + "file": "ui/storybook/stories/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/issue-review-panel.stories.tsx" + ], + "fingerprint": "089b43a36b1cc8cd336f81a5fc654607ffaadcde51a6ace883fc7023850680aa" + }, + { + "file": "ui/storybook/stories/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/issue-thread-interactions.stories.tsx" + ], + "fingerprint": "80ba300809eb79e06600ae4af00a6d9c22c6b83abacb10ba2b6587ffd6092e77" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/AdapterLoginChrome.tsx", + "ui/storybook/stories/local-ai-subscription.stories.tsx" + ], + "fingerprint": "d53b62241b00a9c42cd7caa1c7aa2f6254c48331fce73cb42ccb62e3648744cb" + }, + { + "file": "ui/storybook/stories/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/managed-cloud-oauth-handoff.stories.tsx" + ], + "fingerprint": "8e8abadcb4a6601e5c750843a5ce6a15c8126a72a5d810d15683e36706823fa6" + }, + { + "file": "ui/storybook/stories/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.", + "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/memory-connectors.stories.tsx" + ], + "fingerprint": "5b5206e62ff639874206741e6fa420b80bef9e4bda804c5d2d61f4786d8081ec" + }, + { + "file": "ui/storybook/stories/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/mobile-entity-pickers.stories.tsx" + ], + "fingerprint": "5a1fc8e5d98b3cb562cc80462d90991b8d74b99df2b8842439ef4038116d3af7" + }, + { + "file": "ui/storybook/stories/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/mobile-task-tabs.stories.tsx" + ], + "fingerprint": "ee708c2b2bcbc41dcdddb48738ba2dc52f41e9e304604d2b441eca3cfcc8be45" + }, + { + "file": "ui/storybook/stories/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/model-capacity-retry.stories.tsx" + ], + "fingerprint": "55e83f93b4ecca792c37f241a040e61315cb320b7b98e4b2cf06d6fbe8547f8f" + }, + { + "file": "ui/storybook/stories/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/monitor-surfaces.stories.tsx" + ], + "fingerprint": "7b62af0430a4924c27c06ad8ecdfe9f13e9b9207640b18142611176291589b23" + }, + { + "file": "ui/storybook/stories/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-layout.stories.tsx" + ], + "fingerprint": "322ff8736c38f3c9c0778a2ad232edc916daaf170905330a1594000f17a9b025" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/components/AdapterLoginChrome.tsx", + "ui/src/components/AgentConfigForm.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/new-agent-onboarding.stories.tsx" + ], + "replacement": "ui/storybook/stories/agent-persona-pages.stories.tsx", + "fingerprint": "a4172933b445ae3b6d73008e4fa2b6988c3ef722602ef82f363e288d354441b2" + }, + { + "file": "ui/storybook/stories/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/new-project-repos.stories.tsx" + ], + "fingerprint": "97b3022dc7fcaae73f24d9d486803c6658463849a99ec15aec72d8c2825fc25f" + }, + { + "file": "ui/storybook/stories/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/new-task-composer.stories.tsx" + ], + "fingerprint": "91f49f5ebddbbb2e5d893787c49ddbe9b3a00508ba107f1ff55a4077647d4129" + }, + { + "file": "ui/storybook/stories/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/notion-connect-flow.stories.tsx" + ], + "fingerprint": "059bfb1db9c3d71dca7c10ff6440f588c539d6bb0923007f1b3360b7b322a016" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/onboarding.md", + "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-agent-arc.stories.tsx" + ], + "fingerprint": "74ccb2cd806467152cdd9732e9de09c86b88934364ae50932c3e7d5c5bc87b6f" + }, + { + "file": "ui/storybook/stories/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.", + "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-character.stories.tsx" + ], + "fingerprint": "59566e6ec1b21ccc51e69c7c3a8114594ca4ddb507dfaf09724a28745f8d4f16" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/onboarding.md", + "ui/src/components/onboarding/ConnectModelPreview.tsx", + "ui/storybook/stories/onboarding-connect-model.stories.tsx" + ], + "replacement": "ui/storybook/stories/onboarding-agent-arc.stories.tsx", + "fingerprint": "04ff3c06366ee28c99034af68cbb3935aecdba8c5d106c08124cacc0a0d5a8fc" + }, + { + "file": "ui/storybook/stories/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/originating-tasks.stories.tsx" + ], + "fingerprint": "8529ab64f5d73a48dd0c0fba0ad6de97eb7ae849bc9b87f6d982a0b8cb42965c" + }, + { + "file": "ui/storybook/stories/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.stories.tsx" + ], + "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", + "fingerprint": "e4937a04090633914db07616816556caa1b980e0b1cbb9f9b9d132094f960a6d" + }, + { + "file": "ui/storybook/stories/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/paused-composer.stories.tsx" + ], + "replacement": "ui/storybook/stories/task-execution-controls.stories.tsx", + "fingerprint": "c186d26498e51279f1a262773ccaff918c4f15d8c58f3c1068bd697a6d511a16" + }, + { + "file": "ui/storybook/stories/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/permitted-vs-installed.stories.tsx" + ], + "fingerprint": "b87ffd90db211d079fe708638c65734bd95b07b379ca679e2f1f532fb65c1ea0" + }, + { + "file": "ui/storybook/stories/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/personal-connection-identity.stories.tsx" + ], + "fingerprint": "36c0da3501cf320bffa467227e0ad4cd02d9293f400dfeff43a3e633ca179adb" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agent-chat.md", + "maps/feature-map/agents.md", + "maps/feature-map/tasks.md", + "ui/storybook/stories/primary-agent/00-review-guide.stories.tsx" + ], + "fingerprint": "463e3e9c850aa58719b16f21a4aef72856f612ca825e15b6052845008c927370" + }, + { + "file": "ui/storybook/stories/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.", + "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/primary-agent/01-components.stories.tsx", + "ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "5d6db5bda14b16e0dfec87a3d44e8e805ceefde034b53774d948392edcbb2373" + }, + { + "file": "ui/storybook/stories/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.", + "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/primary-agent/02-sidebar.stories.tsx", + "ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "7407f13fb2987fbd52b0adc72dda0eac39ab209005179b999ad3e1ed4f4b85d2" + }, + { + "file": "ui/storybook/stories/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.", + "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/primary-agent/03-agents.stories.tsx", + "ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "1713ccdafd4b46cf1b91ee6bf3c13c4bae35d2546793a6756b370261df175ef0" + }, + { + "file": "ui/storybook/stories/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.", + "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/primary-agent/04-new-task.stories.tsx", + "ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "ead0839536fb6b3d24449548e4a67cab23d1f704e7753ea1918eb734740c880f" + }, + { + "file": "ui/storybook/stories/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.", + "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/primary-agent/05-chat.stories.tsx", + "ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "b2c0128668180437fb0f416693e9cd91faa272b1dd8e1d376d67623873fb66b1" + }, + { + "file": "ui/storybook/stories/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.", + "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/primary-agent/06-journeys.stories.tsx", + "ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "2dadd05767979d2e9cc2b17486bb33648cb7edc2ef0310ad176854d9351758fc" + }, + { + "file": "ui/storybook/stories/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.", + "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/primary-agent/07-confirmation.stories.tsx", + "ui/storybook/stories/primary-agent/PrimaryAgentStory.tsx" + ], + "fingerprint": "6d3a3ce92995071c644c1032a3c9a3e9551af080e7c5c18507146428d6c7d49d" + }, + { + "file": "ui/storybook/stories/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/primitives-coverage.stories.tsx" + ], + "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", + "fingerprint": "be31785092ae0b06c86bcaa52241ebe2cc209325310c811417cb2d7134a2fa69" + }, + { + "file": "ui/storybook/stories/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/private-tasks/00-review-guide.stories.tsx" + ], + "fingerprint": "bc7b2ff37bfb9d66ee562875119b3ca03fc7ae19ce749f1e06dcd4a3364574ab" + }, + { + "file": "ui/storybook/stories/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/private-tasks/01-creation.stories.tsx", + "ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx", + "ui/storybook/stories/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "cacab6c556db61f247ba64a80a9785b8d4bda1292a255d689ed5b6fc6bdc3428" + }, + { + "file": "ui/storybook/stories/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/private-tasks/02-sharing.stories.tsx", + "ui/storybook/stories/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "a24044c442953bd0cb1b197950b35640894d203e07fd1d3e02e042cc3ad263ed" + }, + { + "file": "ui/storybook/stories/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/private-tasks/03-task-actions.stories.tsx", + "ui/storybook/stories/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "382ac5d8ae287988d64a5eb808be14e86d6e880122f8b0765befd099b965e45f" + }, + { + "file": "ui/storybook/stories/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/private-tasks/04-references-and-blockers.stories.tsx", + "ui/storybook/stories/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "993d88c52dc315938430be9a903a1de1f0a1b7ef3850ae6debab1ba0607cce24" + }, + { + "file": "ui/storybook/stories/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/private-tasks/05-projects.stories.tsx", + "ui/storybook/stories/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "6141c4eaacee9f826673d15cea001ca1ca327f386ec23da03e393ab96820e989" + }, + { + "file": "ui/storybook/stories/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/private-tasks/06-full-product-pages.stories.tsx", + "ui/storybook/stories/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "2acb548a73de6c8b7d6cdb39d9680018400a2739297309b6d0a4246301349ce6" + }, + { + "file": "ui/storybook/stories/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/private-tasks/01-creation.stories.tsx", + "ui/storybook/stories/private-tasks/02-sharing.stories.tsx", + "ui/storybook/stories/private-tasks/03-task-actions.stories.tsx", + "ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx", + "ui/storybook/stories/private-tasks/05-projects.stories.tsx", + "ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx", + "ui/storybook/stories/private-tasks/07-user-stories.stories.tsx", + "ui/storybook/stories/private-tasks/PrivacyStory.tsx" + ], + "fingerprint": "ec90dd616ca5b936fcd597d3da85b62b2f379a6b5308348b55ba60592f4e405d" + }, + { + "file": "ui/storybook/stories/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/project-execution-workspace-strategy.stories.tsx" + ], + "fingerprint": "cfb10722518a7748f98a9320cfae3234388902bdeee9c0ec54cfb9fd817039d2" + }, + { + "file": "ui/storybook/stories/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/project-repos-configuration.stories.tsx" + ], + "fingerprint": "9924dc5dd676d948318714f9ad76cdf08c47be36e4691833928a637ed6e4a03b" + }, + { + "file": "ui/storybook/stories/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-goals-workspaces.stories.tsx" + ], + "fingerprint": "d1a5b5eced02dd205ae8acc07ebe41f29edfeb8fb38434a5c12f9d0927679a7e" + }, + { + "file": "ui/storybook/stories/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.", + "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/provider-routing-00-overview.stories.tsx" + ], + "fingerprint": "6e0da3867aacfd313e24b8b14494536c0dffe2c4927e79371bd263a19e5be30f" + }, + { + "file": "ui/storybook/stories/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.", + "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/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/provider-routing-01-onboarding.stories.tsx" + ], + "fingerprint": "c4867ec22b6b54a872d42fbe210ad0b671e64b920bdcf5e450d88091995c5965" + }, + { + "file": "ui/storybook/stories/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.", + "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/provider-routing-02-connect.stories.tsx" + ], + "fingerprint": "a6cd3df8023c60a14a54dfb399ae3d5c42c42300daa22f6fffd9f8f75d1dd14c" + }, + { + "file": "ui/storybook/stories/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.", + "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/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/provider-routing-03-agent.stories.tsx" + ], + "fingerprint": "826b6dff2a44cbf04f433672b1a05daa6e479d3e4dddfbbe9f1b68b5a08ce90d" + }, + { + "file": "ui/storybook/stories/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.", + "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/provider-routing-04-manage.stories.tsx" + ], + "fingerprint": "2503a1f43bf4d887c466fca9b462ad21b603140dbaff0d3f940a4adcf37898f0" + }, + { + "file": "ui/storybook/stories/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.", + "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/provider-routing-05-recovery.stories.tsx" + ], + "fingerprint": "d028cd3f82eae265db8b93a3ad523fa268929ee897ba6bf10fa5af13821ecb0d" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/AgentConfigForm.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/provider-routing-06-production.stories.tsx" + ], + "fingerprint": "05984862e300309f20b828d08375ebe545c561b02f3bfbf0968fbbc2f18ae210" + }, + { + "file": "ui/storybook/stories/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/public-mcp-connections.stories.tsx" + ], + "fingerprint": "cb92bcca9caaf974d56d500a00abc79c80f84a88330a99f0afa804c2ee5a96e8" + }, + { + "file": "ui/storybook/stories/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/public-mcp-consent.stories.tsx" + ], + "fingerprint": "e1925d8873434a241d8b5acf605210a3b0c179c418cdbb4c96ba08bc208e17c7" + }, + { + "file": "ui/storybook/stories/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/public-mcp-device.stories.tsx" + ], + "fingerprint": "726d36d9d9652a36cae43834ccb12f7bbe98cd0cb1f938c1ddfe5eada05f9a13" + }, + { + "file": "ui/storybook/stories/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/public-mcp-journey.stories.tsx", + "ui/storybook/stories/public-mcp-presenter.tsx" + ], + "fingerprint": "c64f1aa2b77aeb83acece5e1a62f5ce890cf48434cbb07a6fdcfbe37cb5abc33" + }, + { + "file": "ui/storybook/stories/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/public-mcp-setting.stories.tsx" + ], + "fingerprint": "95d870bcd963c30a6ba79bcbb5b0e97db8406d21b044fae6a8e795638428b629" + }, + { + "file": "ui/storybook/stories/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-notice-preview.stories.tsx" + ], + "fingerprint": "844e5749b27fa106b07357e82223ca559d35281e6a4cff086e8d38db048eac95" + }, + { + "file": "ui/storybook/stories/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/github-skills-journey.stories.tsx", + "ui/storybook/stories/repository-skill-packages/repository-skill-packages.stories.tsx" + ], + "fingerprint": "a1931c5b6ddcc74287861c3b0bc014ebc3d37614f96784da9008d63630e04e4e" + }, + { + "file": "ui/storybook/stories/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/rich-acp-interactions.stories.tsx" + ], + "fingerprint": "e707ee55d955565de4be5b7bd33a1c84f0dda43ed7d8a0ad1af5704da409b116" + }, + { + "file": "ui/storybook/stories/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/rich-work-product-cards.stories.tsx" + ], + "fingerprint": "715cfced9ddfd845e74b8cb391faaacd223979f5e4dde95b4e7924302b3822eb" + }, + { + "file": "ui/storybook/stories/routine-detail-c.stories.tsx", + "capabilities": [ + "routines/definition", + "routines/management" + ], + "purpose": "Product/Routines \u00b7 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/routine-detail-c.stories.tsx" + ], + "fingerprint": "803771d302dae639105261ccf7589ca7577f07aadaa060d6015804268a23922b" + }, + { + "file": "ui/storybook/stories/routine-secrets.stories.tsx", + "capabilities": [ + "routines/definition", + "secrets/runtime-delivery" + ], + "purpose": "Product/Routines \u00b7 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/routine-secrets.stories.tsx" + ], + "fingerprint": "03c9f2ff8c2f32316a5ab9e245b5aca89bff831523a8d40672f45fb919d108d7" + }, + { + "file": "ui/storybook/stories/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/routine-webhooks-ux.stories.tsx" + ], + "fingerprint": "79ae3dfb95aa9ec283df353b856a96e35f85a20360305853e3f566dc9e587a97" + }, + { + "file": "ui/storybook/stories/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/routine-webhooks.stories.tsx" + ], + "fingerprint": "3e73dd1236c56f2033765874654ed86418612a91de8c4aa8aaedc71bb751dcfe" + }, + { + "file": "ui/storybook/stories/routines-list-groups.stories.tsx", + "capabilities": [ + "routines/definition", + "routines/management" + ], + "purpose": "Product/Routines \u00b7 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-list-groups.stories.tsx" + ], + "fingerprint": "50c3957e9dcc8fc61ab90352d9646f06108ae1da56ad37a3107f0ca5e782dfe3" + }, + { + "file": "ui/storybook/stories/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.", + "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/runner-activity.stories.tsx" + ], + "fingerprint": "c49b64d79396d3a04dc7ec15d241758fc90ba47785d330fd5024b65e367d2c90" + }, + { + "file": "ui/storybook/stories/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/scheduled-retry.stories.tsx" + ], + "fingerprint": "ca1545d68d032468f9ad897d9942b59c1d8a60b29f2211a47d6e0c191b30279d" + }, + { + "file": "ui/storybook/stories/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/search.stories.tsx" + ], + "fingerprint": "204fd804d9d5ff2235b0a770f2317e245e695c4bc9f069d8d09b4cb99d85b877" + }, + { + "file": "ui/storybook/stories/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/searchable-select.stories.tsx" + ], + "fingerprint": "08f37975b7be5affcdf2905dfa9b7d7a47cb18615591fb6167c563b07cb61533" + }, + { + "file": "ui/storybook/stories/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.stories.tsx" + ], + "fingerprint": "30dcadb034495649535e501bdbd31c41327437f6f96c05937f544cdfb2dd0624" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/runs-adapters.md", + "ui/src/components/task-chat/RunnerGoalWidget.tsx", + "ui/storybook/stories/session-goals.stories.tsx" + ], + "fingerprint": "9bcfb237cf6c16bcb81ecdfbd31110f8709fb9b06d2c60bb3bfe58b2d2314ecc" + }, + { + "file": "ui/storybook/stories/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/skill-import-progress.stories.tsx" + ], + "fingerprint": "b4f466783a0864a6841a650483ffb00ec0999fac0eba6b3a3a38c2f87258b938" + }, + { + "file": "ui/storybook/stories/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/skill-policy-surfaces.stories.tsx" + ], + "fingerprint": "13ba7a87a34ca9aedddb9fe28132238b2064d9e6155a8e4ce7d587a641483f97" + }, + { + "file": "ui/storybook/stories/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-store-detail.stories.tsx" + ], + "fingerprint": "22f4467cce744d7eed66f0c441c549d289dc34c6723ff3c4dd4c7df31e22a262" + }, + { + "file": "ui/storybook/stories/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-store-discovery.stories.tsx" + ], + "fingerprint": "110b1e6168b39c7112d3317e7ef9db33048e77cf2c78331e3cb6f194567f182e" + }, + { + "file": "ui/storybook/stories/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/slack-automatic-setup.stories.tsx" + ], + "fingerprint": "51f957023492730e96a73d3322fceab232e522f4ef15c84e872d8924ddb72149" + }, + { + "file": "ui/storybook/stories/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/slack-avatar-settings.stories.tsx" + ], + "fingerprint": "216835fbd5f463176e6e1eef64df3bfa6325f460004aa0ae0dc22518ef5de2a1" + }, + { + "file": "ui/storybook/stories/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/slack-communication-settings.stories.tsx" + ], + "fingerprint": "100ef834dc5074cc4682a04a329967db5844489b71b56fa410bd76dfa0541ff4" + }, + { + "file": "ui/storybook/stories/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/slack-managed-setup.stories.tsx" + ], + "fingerprint": "010592e2358a425353471763246fa801034c65b83cf01c4eed1e4a82e4ea6ed6" + }, + { + "file": "ui/storybook/stories/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/slack-tools.stories.tsx" + ], + "fingerprint": "9348c9d89befbf34e9eb905b290f7b73b192337cd498ddc028e4ddfe44e7d8bd" + }, + { + "file": "ui/storybook/stories/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/source-issue-recovery.stories.tsx" + ], + "fingerprint": "c7457183c25c8802e6b6b47f1ec8575ed277f834b09aa1ebc5735fe61776f5cd" + }, + { + "file": "ui/storybook/stories/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/source-resolved-fold.stories.tsx" + ], + "fingerprint": "8afd150fe253fae2d9094c6678449693b28e63249f5a852feabd43bed87a1b25" + }, + { + "file": "ui/storybook/stories/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/status-language.stories.tsx" + ], + "exception": "Shared foundations or contributor guide; no single business capability owns this suite.", + "fingerprint": "8514f6eaa6b793e7737f4aef33dd2a1bf1fe47bf7cf106f7fd56be1c1466be26" + }, + { + "file": "ui/storybook/stories/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/sub-issues-workflow.stories.tsx" + ], + "fingerprint": "aab65b7c2d7d976b9f8ceb99d60c1b16d6e6eb97e8443afa48fa3e92561feec3" + }, + { + "file": "ui/storybook/stories/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/successful-run-handoff.stories.tsx" + ], + "fingerprint": "453ca62993784ebcff825853ee3ac3207d6ea0ff0db960a9f6fd9da418bb50e4" + }, + { + "file": "ui/storybook/stories/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.", + "reviewedAt": "2026-10-12", + "sources": [ + "maps/feature-map/agents.md", + "ui/src/components/SummarySlotCard.tsx", + "ui/storybook/stories/summary-slot-card.stories.tsx" + ], + "fingerprint": "8504293cf126375d22ffe6a6fd80c5f1736b5fb03bb01618e5c65c5d9a32bb31" + }, + { + "file": "ui/storybook/stories/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/task-artifact-gallery.stories.tsx" + ], + "fingerprint": "80254d3e29fbbb687300a8fee5900dbb9b6a8b68ce22d95a4a9ae78fe956dc16" + }, + { + "file": "ui/storybook/stories/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.", + "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/task-chat-chain-of-thought.stories.tsx" + ], + "fingerprint": "74ed9bd9d86ccc043226551cf9896ecd7ba85bec03019fcf03facf4d20a360b6" + }, + { + "file": "ui/storybook/stories/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/task-chat-interaction-above-composer.stories.tsx" + ], + "fingerprint": "917b21b457c6c173cfcfc5a6e1d6ec54bbbee92a4d04bc2be5a6c4928984681c" + }, + { + "file": "ui/storybook/stories/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/task-chat-mobile-follow.stories.tsx" + ], + "fingerprint": "3ccff6b7e818d1691c0287229b7f5ef18aa2668332fcd873cc6f188d49d69e73" + }, + { + "file": "ui/storybook/stories/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/task-chat-system-notice.stories.tsx" + ], + "fingerprint": "1cd42408b2c31320e2dec854dd0dec86ae6fe9328b5c9bf0bc7d82c7acdffd35" + }, + { + "file": "ui/storybook/stories/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/task-chat-unanswered-questions.stories.tsx" + ], + "fingerprint": "402f78a40546ce9b4a4b9fc28f995ec18e9c27702e6ba8df609a8f4d5f001fee" + }, + { + "file": "ui/storybook/stories/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/task-context-notices.stories.tsx" + ], + "fingerprint": "550059ae3c321e98ae8273caf7ff8d33d4702703e7bfaa3e13aa7a33b8e0efc2" + }, + { + "file": "ui/storybook/stories/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/task-execution-controls.stories.tsx" + ], + "fingerprint": "2ab3cccc76d0a055efbd97501e257088470150423473d77651057229bf5a612a" + }, + { + "file": "ui/storybook/stories/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/task-text-files.stories.tsx" + ], + "fingerprint": "88992903362472d8587168537357335425e6239270bafe02e05d108c72b1a69b" + }, + { + "file": "ui/storybook/stories/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-watchdog-surfaces.stories.tsx" + ], + "fingerprint": "43b0522a20b83ff2fc4833e7d62249098564f42f64bbfcc0c26537d93a650d3e" + }, + { + "file": "ui/storybook/stories/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/team-catalog.stories.tsx" + ], + "fingerprint": "d6ff514c0048eb1cb47965cb2e7af54d3483f5921bf0957fee96612d34f51e1d" + }, + { + "file": "ui/storybook/stories/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/thread-human-identity/00-evidence.stories.tsx", + "ui/storybook/stories/thread-human-identity/fixtures.ts" + ], + "fingerprint": "f2f790b481362ed01fbffea8688613804b416f9bc488301adf59993bba4d8f72" + }, + { + "file": "ui/storybook/stories/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/thread-human-identity/01-components.stories.tsx", + "ui/storybook/stories/thread-human-identity/fixtures.ts" + ], + "fingerprint": "7d18f314cbbcaacca17b800d8a64e4ab2c42f5b723aefcf03b0e21cbf41d9476" + }, + { + "file": "ui/storybook/stories/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/thread-human-identity/02-task-journey.stories.tsx", + "ui/storybook/stories/thread-human-identity/fixtures.ts" + ], + "fingerprint": "6af2fa024cc2a7ef17d355bab43fc4f10132ff596b71d93ae0327aaadc0323cd" + }, + { + "file": "ui/storybook/stories/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/user-secrets.stories.tsx" + ], + "fingerprint": "52de3176504986226f5a3e1d8e0fb1a3b0caafdd71da6656d9bdcfa4bd87ef52" + }, + { + "file": "ui/storybook/stories/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/ux-labs.stories.tsx" + ], + "fingerprint": "3570ef2e7b2b4c84e47614b2a907c67d97128a1a7f8f2307527da66c7a0443bf" + }, + { + "file": "ui/storybook/stories/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/what-needs-me.stories.tsx" + ], + "fingerprint": "2ddefa4378c04219b88c9b9f33973aa3c892ec173cdac764588521001940dce0" + }, + { + "file": "ui/storybook/stories/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/work-timeline.stories.tsx" + ], + "fingerprint": "39930f80f4b2758c9887093bc34094f07e07e058ec77707e7dc7dafdbcae8826" + }, + { + "file": "ui/storybook/stories/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/workspace-branch-recovery/01-component.stories.tsx", + "ui/storybook/stories/workspace-branch-recovery/fixtures.ts" + ], + "fingerprint": "23eb031735a6d86350ab65cf7136f83ec156527e701bc731cef2543b59cfea82" + }, + { + "file": "ui/storybook/stories/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/workspace-branch-recovery/02-task-journey.stories.tsx", + "ui/storybook/stories/workspace-branch-recovery/fixtures.ts" + ], + "fingerprint": "5f196b568769b5a1ae4be18b710c7c8d330ded45d15fa62109d6a6daf2c6dec5" + }, + { + "file": "ui/storybook/stories/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/workspace-file-browser.stories.tsx" + ], + "fingerprint": "dd09003a9523f2c234445d60827125ec9b5c3df732ec54631d86a5be91447fc8" + }, + { + "file": "ui/storybook/stories/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/workspace-runtime-provisioning.stories.tsx" + ], + "fingerprint": "b91646a603aefc363fc3e2e2e5ab54f785825ba66f5b86d5a0c75e135efb16a1" + }, + { + "file": "ui/storybook/stories/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/workspace-service-control-bar.stories.tsx" + ], + "fingerprint": "4c18f2b9ac0ba5512478f554054a5be973b68211f15caff8875d2abb4ea7fdab" + } + ] +} diff --git a/scripts/storybook-catalog.mjs b/scripts/storybook-catalog.mjs new file mode 100644 index 00000000000..e1f1f4a2798 --- /dev/null +++ b/scripts/storybook-catalog.mjs @@ -0,0 +1,134 @@ +#!/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 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, 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 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`); + 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..c82059060a6 --- /dev/null +++ b/scripts/storybook-catalog.test.mjs @@ -0,0 +1,114 @@ +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/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/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, 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/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"; + assert.deepEqual(validateCatalog(f.root, f.catalog, f.stories), []); +}); + +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", 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", ids: ["other-subject--included"], plays: 0, + }); +}); diff --git a/ui/storybook/prototypes/README.md b/ui/storybook/prototypes/README.md index c187b519da3..5f6666cd497 100644 --- a/ui/storybook/prototypes/README.md +++ b/ui/storybook/prototypes/README.md @@ -9,7 +9,7 @@ pnpm --filter @paperclipai/ui exec storybook dev --port 6010 --host 127.0.0.1 -- ## Connector catalog chips **Apps → Catalog source selection → Chips** is the focused catalog preview. -The four text chips are **Paperclip**, **Composio**, **Installed**, and **All**. +The five text chips are **Paperclip**, **Composio**, **Arcade**, **Installed**, and **All**. The selected chip has a neutral pill background; the other choices are plain text. Connected accounts stay above discovery. Installed shows only saved native and Composio apps. All includes every catalog source, including Arcade. Searching diff --git a/ui/storybook/stories/agent-chat.stories.tsx b/ui/storybook/stories/agent-chat.stories.tsx index 35d34c87cfe..592dde03355 100644 --- a/ui/storybook/stories/agent-chat.stories.tsx +++ b/ui/storybook/stories/agent-chat.stories.tsx @@ -9,7 +9,7 @@ const meta = { docs: { description: { component: - "Uses the actual production Layout (Sidebar, BreadcrumbBar, PropertiesPanel), TaskChatThread (TaskChatComposer, harness activity, thinking/tool disclosures, responses), and TaskSidePanel (plans, artifacts, subtasks). Agent shortcuts compose the existing sidebar rows and sections: starred agents first, the first-created agent as a default, then recent conversations. The compose button opens a searchable picker for every agent, and the gear link opens agent configuration. The task surfaces differ only in breadcrumb/title and initially open panel tabs. All data is fixture data; sends append locally and unsupported mutations fail explicitly.", + "Production Layout, AgentChat, IssueDetail, transcript and side-panel components run against in-memory routes. The landing page is a local ChatEntryLanding composition; first-use/default-agent behavior is not production acceptance. Sends append locally. Some unhandled API paths return empty fixture data, so this suite does not verify API contracts or primary-agent preference behavior.", }, }, }, diff --git a/ui/storybook/stories/chat-entry.stories.tsx b/ui/storybook/stories/chat-entry.stories.tsx index a04145fb648..539d5721fe0 100644 --- a/ui/storybook/stories/chat-entry.stories.tsx +++ b/ui/storybook/stories/chat-entry.stories.tsx @@ -6,7 +6,7 @@ const meta = { title: "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/fast-responses/01-settings.stories.tsx b/ui/storybook/stories/fast-responses/01-settings.stories.tsx index 73b61216653..4ed8d04936f 100644 --- a/ui/storybook/stories/fast-responses/01-settings.stories.tsx +++ b/ui/storybook/stories/fast-responses/01-settings.stories.tsx @@ -37,6 +37,7 @@ export const result: FastResponseTestResult = { }; const meta = { title: "Fast responses/01 Settings", + excludeStories: ["empty", "configured", "result"], component: FastResponseSettingsView, decorators: [ (Story) => ( @@ -52,6 +53,7 @@ const meta = { onSave: fn(), onTest: fn(), onAdd: fn(), + onConnectionChange: fn(), }, } satisfies Meta; export default meta; diff --git a/ui/storybook/stories/overview.stories.tsx b/ui/storybook/stories/overview.stories.tsx index c95b2126a69..c7e1ab8f647 100644 --- a/ui/storybook/stories/overview.stories.tsx +++ b/ui/storybook/stories/overview.stories.tsx @@ -1,203 +1,92 @@ +import { useEffect, useMemo, useState } from "react"; 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"; +import { Input } from "@/components/ui/input"; +import catalog from "../../../maps/storybook-map/catalog.json"; -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"], -]; +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 ( -
-
-
-
-
-
- - 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} - +
+
+

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}
    -
    {detail}
    -
+

{entry.notes}

+

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

+ ))} -
+
-
-
+ ))} + ); } 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.", - }, - }, - }, + 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/task-chat-interaction-above-composer.stories.tsx b/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx index 5fe19d676e0..b8f896f8e44 100644 --- a/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx +++ b/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx @@ -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} /> ); @@ -143,7 +148,7 @@ const meta = { 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 +159,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(); }, }; From dabb8c5daf430a1987af611e2e7ca4c7b23ef7d4 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:23:11 -0500 Subject: [PATCH 6/6] test: remove repeated setup and strengthen regression proof (#16022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its tests protect company boundaries, approvals, durable tasks, credentials and the board UI. > - Several owner suites repeat large database fixtures and render frames. > - Some tests repeat stronger proof or pass when the behavior they describe is broken. > - This pull request shares exact setup, removes proven duplicate cases and repairs weak observations. > - It preserves distinct security, lifecycle, concurrency and user-facing contracts. > - The benefit is 5,027 fewer test lines and stronger regression checks. ## Linked Issues or Issue Description Refs #15978. This continues the merged test-audit cleanup in [PR #15978](https://github.com/paperclipai/paperclip/pull/15978), which removed 11,976 test/support lines. The savings in this pull request are additional. Related open work: [PR #13460](https://github.com/paperclipai/paperclip/pull/13460) changes legacy OAuth parent/version lock ordering. This pull request retains those production paths and the existing lock-order test. Its secrets changes compact fixtures and repair the two AWS wrapper exclusion observations. Scoped title searches found no open duplicate audit or fixture-consolidation PR and no matching audit issue. The problem is described here using the enhancement template. **What existing behavior does this improve?** The maintenance and regression value of the existing server, UI, CLI, shared and wiki-plugin test suites. **Subsystem affected** Cross-cutting test code. Two UI modules also lose unused test inspection exports. **Current behavior** Owner tests repeat database rows, service inputs, HTTP setup and render frames. Some cases repeat stronger assertions. Two AWS lock tests record only cleanup events, so an AWS provider write can happen inside the held lock without failing those observations. Several other negative tests accept or do not observe the wrong result. Worktree fixtures also use a temporary path alias that conflicts with the existing canonical-path checks on macOS. **Proposed behavior** Use small local helpers that preserve exact inputs, fresh allocations, callbacks, query order and await positions. Remove only cases with named stronger keepers. Tighten negative assertions and observe actual AWS provider entry while a confirmed lock holder remains parked. Resolve temporary fixture roots to their canonical paths. **Reason and benefit** Remove 5,027 net test lines and 15 redundant registered executions. Remove 11 lines of unused UI inspection exports. Keep every distinct company, actor, approval, credential, persistence, stale-write and concurrency contract. No wall-time speedup is claimed for setup compaction. **Breaking changes** None for application behavior, APIs or stored data. The removed exports expose private UI test state and have no remaining callers. ## What Changed - Consolidate repeated runtime, workspace, portability, route, tool-access, policy, gateway and state-service fixtures. - Share repeated UI render frames and CLI/wiki-plugin setup while retaining the original actions and expected results. - Remove 15 repeated case executions only where the audit records an exact stronger keeper. - Repair weak HTTP/UI negative assertions and two AWS account-home lock observations. Keep the existing durable results and cleanup behavior. - Remove the unused `__inflightGetCount` and `__secondTickInternals` inspection exports. Keep observable request coalescing and ticker lifecycle checks. - Canonicalize temporary worktree fixture paths without weakening production path checks. - Record the owner decisions, savings, independent preservation reviews and validation in [the dated audit report](https://github.com/paperclipai/paperclip/blob/58c5c4aa241c85f4665a3159c192888bbb2a5cfa/doc/plans/2026-10-12-test-audit-rounds.md) and [the compact control evidence](https://github.com/paperclipai/paperclip/blob/58c5c4aa241c85f4665a3159c192888bbb2a5cfa/doc/plans/2026-10-12-test-audit-controls.json). ## Verification - Complete owner and retained-sibling baselines passed before each audit cutover. Independent reviewers checked the full original tests, actual helper bodies, exact inputs, receipt provenance, async order, fresh allocations and case registrations. - All 14 UI/route/wiki mutations failed at their intended assertions: ten UI/API/ticker controls, one route control and three wiki persistence/receipt controls. Each mutated production source was restored to its exact original bytes. - Both old AWS lock tests passed with their respective AWS-only lock bypass. Both repaired tests failed at the intended assertions with the same bypasses. These give 16 intended failures and two passing weak controls across 18 executions. Each source was restored exactly. - Final complete state-owner/sibling run: 329/329 passed at code revision `64370831ebe191e5ce092f85f79b6fb661df7e16`. - Final `pnpm -r typecheck`, `pnpm check:token-gates` and `pnpm build`: passed at the same code revision. - Delivery head `58c5c4aa241c85f4665a3159c192888bbb2a5cfa` includes the evidence commit and master `4b766b872993045a2e787404f0475503683e5ded`. All 27 audited test files and both application files match the locally verified code revision byte-for-byte. Current-head CI validates the integrated branch, including the incoming master changes. - All eight production-owner SHA guards for the 16 deliberate failing mutation controls still match the integrated delivery checkout. The mutation receipts therefore apply to those current owners. - Full default `pnpm test:run`: **failed** with exit 1 at frozen code revision `64370831ebe191e5ce092f85f79b6fb661df7e16`; HEAD and tracked source bytes remained unchanged. The first general-server group reported 878 passing files, four failing files and six skipped files; 17,846 passing cases, 24 failing cases and 105 skipped cases. Later groups did not run. Failures were `http-log-redaction` (socket hangup), `public-mcp` (initial 15-second timeout followed by hosted token “Refresh before subscribing” failures), `remove-test-home` (macOS `ENOTEMPTY` where permission errors were expected, plus cleanup failure), and `workspace-git-snapshot-streaming` (300-second timeout). All four failing test files and the relevant cleanup/MCP production owners are unchanged by this audit. Full local success is not claimed. - Focused complete-owner diagnosis at the same frozen revision: 149/152 passed, three failed, zero skipped. `public-mcp` passed 82/82 and `http-log-redaction` passed 66/66 unchanged. The two `remove-test-home` cases reproduced macOS `ENOTEMPTY` against an expected `EACCES`/`EPERM` contract; the 32 MiB workspace snapshot case reproduced its existing 300,000 ms timeout. HEAD and tracked files stayed clean. Exact blob comparison confirmed all four test files and the relevant cleanup/MCP owners match both inspected master `4b766b872993045a2e787404f0475503683e5ded` and the frozen local revision. The three remaining local failures are unresolved; no timeout, retry or assertion was weakened. - Current-head CI: [workflow 38299262149](https://github.com/paperclipai/paperclip/actions/runs/38299262149) has passed runner selection and created all downstream test/typecheck/build gates; those gates are queued, with no failures reported. Greptile completed successfully at `58c5c4aa241c85f4665a3159c192888bbb2a5cfa` with [5/5 and zero comments](https://github.com/paperclipai/paperclip/pull/16022#issuecomment-6131457592). All review threads are resolved or absent. This pull request will remain open after verification. Reviewers can reproduce the required workspace checks: ```sh pnpm -r typecheck pnpm check:token-gates pnpm test:run pnpm build ``` Browser suites were not run. Their sources and user journeys are unchanged by this audit. ## Risks - A fixture helper can hide a specialized input or change object freshness. Full-program expansion checks and independent reviews preserve the exact old inputs and execution order. - Removing duplicate proof can remove a distinct contract by mistake. The audit records the keeper for each deletion; all distinct security and lifecycle cases remain. - Concurrency checks can miss progress during a fixed delay. The AWS repairs record actual provider entry, confirm holder entry, use measured entry windows, require exact final event order and drain both operations in `finally`. - The full local default run failed. Focused diagnosis reproduced two macOS cleanup failures and one existing snapshot timeout in unchanged owners; these remain unresolved. Current-head CI is pending. No test deadline, retry policy, skip or CI shard was weakened to obtain a pass. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex with GPT-6, reasoning, repository inspection, code execution and delegated audit/review. The runtime does not expose the exact served model identifier or context-window size; those values are unavailable and are not inferred from the model catalog. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- cli/src/__tests__/company.test.ts | 317 +-- cli/src/__tests__/worktree.test.ts | 207 +- doc/plans/2026-10-12-test-audit-controls.json | 539 ++++ doc/plans/2026-10-12-test-audit-next.md | 121 + doc/plans/2026-10-12-test-audit-rounds.md | 92 + .../plugin-llm-wiki/tests/plugin.spec.ts | 270 +- packages/shared/src/app-definitions.test.ts | 20 - .../shared/src/worktree-seed-source.test.ts | 6 +- .../src/__tests__/agent-skills-routes.test.ts | 108 +- .../src/__tests__/company-portability.test.ts | 146 +- .../__tests__/company-skills-routes.test.ts | 512 +--- .../src/__tests__/environment-routes.test.ts | 500 +--- .../src/__tests__/environment-runtime.test.ts | 2001 ++++---------- .../execution-workspaces-service.test.ts | 347 +-- .../instance-settings-routes.test.ts | 114 +- .../issue-comment-reopen-routes.test.ts | 428 +-- .../issue-thread-interactions-service.test.ts | 574 +--- server/src/__tests__/secrets-service.test.ts | 607 ++--- .../tool-access-policy-service.test.ts | 289 +- .../src/__tests__/tool-access-service.test.ts | 784 ++---- .../__tests__/tool-gateway-service.test.ts | 297 +-- ui/src/api/client.test.ts | 20 +- ui/src/api/client.ts | 5 - .../AgentConfigForm.render.test.tsx | 645 ++--- ui/src/components/IssueChatThread.test.tsx | 2345 ++++++----------- ui/src/components/IssueProperties.test.tsx | 182 +- .../IssueThreadInteractionCard.test.tsx | 40 +- ui/src/components/MarkdownBody.test.tsx | 22 +- ui/src/components/OnboardingWizard.test.tsx | 192 +- ui/src/hooks/useSecondTick.test.tsx | 32 +- ui/src/hooks/useSecondTick.ts | 6 - ui/src/pages/IssueDetail.test.tsx | 522 +--- 32 files changed, 4002 insertions(+), 8288 deletions(-) create mode 100644 doc/plans/2026-10-12-test-audit-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-next.md create mode 100644 doc/plans/2026-10-12-test-audit-rounds.md 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/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 ` { "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/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/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/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 9f0848f2d52..0bcf3dbaaf3 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -249,6 +249,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 () => { vi.resetModules(); @@ -305,22 +316,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 = { @@ -463,61 +460,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(), @@ -540,14 +487,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(), @@ -588,14 +528,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") @@ -816,14 +749,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__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index b5c265a2a96..8d7f1ca34bf 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -507,6 +507,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}`); @@ -690,14 +701,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:"); @@ -779,14 +783,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"); @@ -1763,14 +1760,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: { @@ -3306,14 +3296,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:"); @@ -3350,14 +3333,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([]); @@ -3430,14 +3406,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", @@ -3503,14 +3472,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([]); @@ -3570,14 +3532,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([]); @@ -3622,14 +3577,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([]); @@ -3720,14 +3668,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([]); @@ -3802,14 +3743,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([]); @@ -5500,14 +5434,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"); @@ -5559,14 +5486,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([]); @@ -5743,14 +5663,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({ @@ -5819,14 +5732,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__/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__/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__/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__/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__/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/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 57103160fa6..d985d3e0895 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -262,6 +262,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 = {}, @@ -281,35 +321,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, + ); }); @@ -327,12 +356,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, @@ -342,21 +366,15 @@ async function renderCreateForm( const onChange = vi.fn(); await act(async () => { - root.render( - - - - - - - , + renderFormView(root, queryClient, + ); }); @@ -461,15 +479,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 }, ); @@ -480,15 +490,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 }, ); @@ -500,15 +502,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 }, ); @@ -516,15 +510,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 }, ); @@ -534,15 +520,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 }, ); @@ -560,9 +538,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: { @@ -588,15 +564,7 @@ async function renderStatefulCreateClaudeSandbox(environments: Environment[]) { } await act(async () => { - root.render( - - - - - - - , - ); + renderFormView(root, queryClient, ); }); await flushReact(); @@ -967,15 +935,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 ?? ""; @@ -992,15 +952,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); @@ -1014,15 +966,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); @@ -1265,15 +1209,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 }, ); @@ -1344,15 +1280,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 }, ); @@ -1381,15 +1309,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 }, ); @@ -1407,15 +1327,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 }, ); @@ -1432,15 +1344,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 }, ); @@ -1457,23 +1361,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); @@ -1504,15 +1398,7 @@ describe("AgentConfigForm environment selector", () => { } await act(async () => { - root.render( - - - - - - - , - ); + renderFormView(root, queryClient, ); }); await flushReact(); @@ -1777,12 +1663,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(); @@ -1814,25 +1700,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 @@ -1869,25 +1747,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); @@ -1900,6 +1770,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 () => { @@ -1931,25 +1802,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); @@ -2007,25 +1870,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); @@ -2079,25 +1934,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); @@ -2130,7 +1977,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"); @@ -2182,23 +2029,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. @@ -2218,15 +2055,7 @@ describe("AgentConfigForm environment selector", () => { ); } await act(async () => { - root.render( - - - - - - - , - ); + renderFormView(root, queryClient, ); }); await flushReact(); @@ -2733,15 +2562,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); @@ -2781,24 +2602,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); @@ -2818,25 +2631,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 @@ -2858,24 +2663,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"))); @@ -2900,24 +2697,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"))); @@ -3305,42 +3094,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(); @@ -3627,44 +3400,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/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.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index c6294d78ac4..fcd0ad19144 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -273,6 +273,16 @@ async function flushReact() { }); } +function renderWizardView(root: ReturnType, queryClient: QueryClient) { + return act(async () => { + root.render( + + + , + ); + }); +} + const SESSION_USER_ID = "user-b"; function render() { @@ -422,13 +432,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 }; @@ -516,13 +520,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) => { @@ -592,13 +590,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(); @@ -772,13 +764,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) => { @@ -842,6 +828,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()); }); @@ -1240,31 +1228,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(); @@ -1321,13 +1285,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(); @@ -1394,13 +1352,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; @@ -1451,13 +1403,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(); @@ -1513,13 +1459,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 @@ -1553,13 +1493,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 @@ -1598,13 +1532,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(); @@ -1641,13 +1569,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... @@ -1689,13 +1611,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); @@ -1733,13 +1649,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. @@ -1787,13 +1697,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. @@ -1823,13 +1727,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(); @@ -1861,13 +1759,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. @@ -1903,13 +1795,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. @@ -1927,13 +1813,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(); @@ -2002,13 +1882,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 @@ -3052,6 +2926,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()); }); 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/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(() => {