mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/147/changes/paperclip/147 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People coordinate work through conversations, tasks, and requests for input. > - These surfaces must share task state and preserve the context of each decision. > - The existing workspace makes people switch surfaces to follow that context. > - This pull request adds an optional Coordinator workspace with linked conversations, tasks, documents, and decisions. > - Each person can enable it in Settings > Experimental and return to the classic workspace. > - The benefit is a coherent path from a request to an assigned task and reviewed work. ## Linked Issues or Issue Description **Subsystem affected** UI, server, shared contracts, and database. **Problem or motivation** People need a consistent way to start tasks from agent conversations, answer questions, review work, and return to the source context. Decisions and task state must agree across these surfaces. **Proposed solution** Add a personal, default-off workspace experiment. Keep one conversation per agent. Add task proposals, compact task and decision cards, contextual document panels, and a Needs You queue with search, filters, sorting, grouping, and history. **Alternatives considered** Replacing the classic workspace for everyone would remove the opt-out path. Keeping separate decision state in each surface would create conflicting answers. This change uses the existing canonical task and interaction records instead. **Roadmap alignment** This supports the existing agent chat, task management, and human review capabilities. It is an experimental presentation choice. The user requested this isolated implementation after design review. No Discord discussion is claimed. ## What Changed - Add per-person workspace preferences, a default-off setting, and an additive migration. - Add Coordinator navigation, pinned destinations, task lists, agent switching, and contextual panels. - Link task suggestions and creation to the source conversation. Default new task ownership to the current agent unless the user specifies another owner. - Share pending questions, decision receipts, exact review targets, and history across tasks, chat, and Needs You. - Preserve task access, assignment authorization, revision binding, and required output review gates. - Add compact document links, relative timestamps, composer controls, and reduced-motion support for task and decision transitions. - Add component stories, regression coverage, and capability-map documentation. ## Verification - Final head: `4b839aae1e314bd7e8635a766f1d205ff1c03ba6`, based on master `8bc051245f77a47bee0f8ba45896b8bfa5922e0a`. Verified October 29, 2026 at 00:10 UTC: **MERGEABLE, 56 checks passed, four conditional skips, no pending or failed checks**. [Current CI run](https://github.com/paperclipai/paperclip/actions/runs/40790639688) passed without a rerun on this head. - Fresh Greptile **5/5**, zero new comments and zero open review threads; Superagent passed with no suspicious changes. [Checks](https://github.com/paperclipai/paperclip/pull/17983/checks). - Local workspace typecheck/build passed after the latest rebase. The final account-read patch passed UI typecheck/build and all 12 related preference/settings tests, including a delayed real-client request across account changes. Earlier scoped checks cover review deduplication/replay, decision deep links, cross-surface state, access boundaries, and migration replay. - Local database/browser rechecks after the latest rebase were limited by exhausted macOS shared-memory IDs: `initdb` failed before database creation. The local run passed 31 UI/snapshot tests and skipped 24 database tests; those skips and the blocked local browser start are not claimed as passes. The independent Linux CI database and browser checks passed. No running dev database or host setting was changed. - Upstream migration 0403 is preserved. Preferences and durable review retry aliases are generated as replay-safe migrations `0404_tense_silver_sable.sql` and `0405_harsh_avengers.sql`; five newest snapshots are retained. Every detected conflict was rebased and pushed before waiting for CI/review, as requested. - The PR is unmerged. The active master ruleset still requires code-owner approval for the changed Paperclip skill; no approval or rule change was performed. <details> <summary>Earlier verification failures, integration history, and evidence</summary> These entries preserve prior checkpoints and failures; the current acceptance result is above. - Focused UI and server checks from the isolated build are recorded in the implementation plan. - Browser checks covered the opt-in shell, navigation, task/document links, and decision card states. No new live provider trial is claimed for this handoff. - Current acceptance head: `4b839aae1e314bd7e8635a766f1d205ff1c03ba6`, rebased onto master `8bc051245` immediately after its GitHub review-repair migration took 0403. Preferences are generated as idempotent `0404_tense_silver_sable.sql`, preserving upstream SQL/journal/snapshot history and five newest snapshots. GitHub reports MERGEABLE after the immediate lease-protected push. Scott's updated instruction is to rebase and push every conflict resolution first, without waiting for CI or Greptile; verification follows the push. - The final reviewed feature tree was consolidated for repeatable single-commit rebases. The complete previous commit history remains reachable through local recovery ref `codex/coordinator-recovery-before-rebase-20261028` (resolved snapshot `0e1bbdf4e`). The actual rebase completed, and its tree hash exactly matches that resolved snapshot. The force-with-lease required the known remote head `0e6ef7c44`, protecting against concurrent branch changes. No PR merge or permission/runner setting change occurred. Fresh CI/review are in progress for the current head. - `2001132fa` received Greptile 5/5 with one nonblocking account-switch read finding. Current head detaches path-coalesced preference GETs and disables HTTP caching, retaining React Query's account-scoped sharing. A delayed real-client GET regression proves account B can load its saved choice before account A responds, and A's late response only updates A's cache. Preference/settings tests 12/12 and UI typecheck passed; its review thread is resolved. Fresh current-head CI/reviews are pending. The prior CI chat shard 3 ended with “The operation was canceled” and no assertion failure ([job 123171595631](https://github.com/paperclipai/paperclip/actions/runs/40789688333/job/123171595631), `/tmp/pr17983-2001132-chat3.log`); no code/timeout change is attributed to that cancellation. - `843e823b4` reached fresh Greptile 5/5 with zero comments/open threads and Superagent pass. All browser tests including the repaired YAML shard passed, and 54 checks passed. General server shard 9 ended with “The operation was canceled” and no failed assertion; its test step was marked cancelled (job [123164350893](https://github.com/paperclipai/paperclip/actions/runs/40787501761/job/123164350893), log `/tmp/pr17983-843e-server9.log`). A single unchanged-head rerun was started after the workflow completed, but master moved while it ran. A local nested-clone check timed out in its 30-second database-startup hook and then failed cleanup before the 20 database cases ran; this is retained as a local verification limit (`/tmp/pr17983-843e-nested-clone-check.log`), not claimed as a pass or proof of the CI cancellation cause. - Following Scott's instruction, the new conflict was rebased and pushed immediately without waiting for that rerun. Upstream's `0403_natural_cerise.sql` is preserved; regenerated preferences and retry-alias migrations are `0404_tense_silver_sable.sql` and `0405_harsh_avengers.sql`, replay-safe with five newest snapshots retained. The YAML helper conflict combines upstream's click and header behavior with the editor-selection guard and injected regression. Recovery ref `codex/coordinator-recovery-843e823b4` preserves pre-rebase work. Current head is MERGEABLE. Full workspace typecheck/build passed; UI and snapshot checks passed 31 tests, but 24 database tests were skipped by the local support probe, and the local browser server could not initialize PostgreSQL. A direct disposable `initdb` diagnostic established the host limitation: `could not create shared memory segment: No space left on device` (`shmget`, shared-memory IDs exhausted, not disk space). No dev database or host setting was changed. Diagnostics: `/tmp/pr17983-2001132-initdb-probe.log`, `/tmp/pr17983-2001132-db-probe.log`; checks: `/tmp/pr17983-2001132-tests.log`, `/tmp/pr17983-2001132-browser.log`, `/tmp/pr17983-2001132-types.log`, `/tmp/pr17983-2001132-build.log`. These local skipped/blocked checks are not claimed as passes; fresh CI on independent runners is in progress. - `d18f683cd` reached fresh Greptile 5/5 with zero comments/open threads and Superagent pass, but [E2E shard 7](https://github.com/paperclipai/paperclip/actions/runs/40785966243/job/123159719925) failed parsing an Advanced YAML clipboard read beginning with “Skip to Main Content.” The log is retained at `/tmp/pr17983-d18f-e2e7.log`. Current head hardens the test helper to verify editor focus and selection, clear stale clipboard content, and require the YAML header at the start of a fresh copy before parsing. Saved-state assertions and timeouts are unchanged. All six Advanced YAML browser journeys passed on a disposable local instance (41.0s); the exact lost-response journey also passed with a one-time injected surrounding-page copy, verifying rejection/reacquisition (17.6s). The precise browser focus/clipboard timing in CI remains unproven; the observed invalid-read path is now guarded and deterministically exercised. Logs: `/tmp/pr17983-yaml-clipboard-scope-browser.log`, `/tmp/pr17983-yaml-clipboard-injected-browser.log`. Fresh current-head CI and reviews are pending. - `19e6dcb0b` passed Superagent and Greptile 5/5, with one nonblocking deep-link finding. Current head preserves attention links using `?decisionId=`: the requested pending decision is rendered even beyond the first 40 cards, focused and scrolled into view, then only that parameter is consumed so later refreshes do not refocus. Other URL parameters remain. Needs You tests 10/10 and UI typecheck passed; its review thread is resolved. The preceding post-rebase regression run passed 45/45 tests. Fresh current-head CI/reviews are pending. - `6d772b7cc` reached Greptile 5/5 with zero comments/open threads and Superagent pass. While 32 checks had passed, master moved and caused a documentation conflict in `maps/feature-map/onboarding.md`. Rebased immediately, preserving both onboarding paragraphs. The complete four-commit branch was pushed as `19e6dcb0b` before waiting on CI/review, per Scott's instruction. A transient Git index lock interrupted the first continue; the command sequence briefly pushed intermediate `34e298c07`, then the completed rebase was immediately pushed. All feature and security/retry fixes are present; local recovery ref `codex/coordinator-recovery-6d772b7cc` preserves the pre-rebase history. The only tree differences from that ref are upstream's two smoke-test files and the combined documentation paragraph. GitHub reports MERGEABLE. Fresh current-head verification is in progress; prior-head review results are not current acceptance. - `21efa3120` passed Superagent but Greptile scored 4/5 for a valid retry-key persistence bug: an acknowledged duplicate key could reopen a resolved review. Current head adds an additive, replay-safe `0404_light_swarm.sql` migration for indexed, task-scoped aliases from acknowledged retry keys to the canonical interaction. Alias registration and retry lookup occur under the existing task lock; payload/actor mismatch still conflicts, and each canonical review permits at most 100 aliases. Original and acknowledged keys replay the terminal result rather than creating a new gate. Five newest migration snapshots are retained. Service/routes tests 165/165 passed; final quality review plus migration replay/snapshot checks 25/25 passed; server typecheck passed. The P1 thread is resolved in code; fresh current-head reviews and CI are pending. - `69c2c7543` passed 55 checks and Greptile 5/5 with no open Greptile findings, but Superagent check [123140675812](https://github.com/paperclipai/paperclip/pull/17983/checks?check_run_id=123140675812) correctly flagged unbounded pending quality-card creation. Current head fixes that under the existing task-row lock: equivalent requests with fresh idempotency keys reuse the pending card; conflicting requests for an unresolved deliverable return the existing interaction ID; pending cards are capped at ten per task across all reviewers. Distinct outputs remain independent, and already-answered items in a partially pending batch can receive a fresh review. Added real-database concurrency, cross-card duplicate, partial-answer, and hard-cap regressions. Quality review 22/22 tests and final server typecheck passed; earlier service/routes 163/163 tests passed before the final partial-answer refinement. One added assertion initially expected a service HTTP error, but duplicate keys are rejected earlier by the existing shared schema; the assertion now verifies that validation. Fresh current-head CI/reviews are pending; the action-required security result is not treated as passed. - `c9c575e5f` completed all 56 checks successfully with four conditional skips, Greptile 5/5, and Superagent pass. Its two P2 review findings are fixed in the current head: independently bound quality approvals now each remain visible in attention; changing/clearing URL-backed assignee/status filters updates the URL and survives task navigation. Both review threads are resolved. Regression evidence: attention service 34/34 tests passed, CoordinatorTasks 20/20 passed, UI and server typechecks and the full workspace build passed on `69c2c7543`. The first new UI test run caught an assertion racing React Router's transition; waiting for the navigation before asserting passes without weakening the navigation/remount checks. These older-head green checks are not claimed for the new fix commit. - Preceding `0e6ef7c44` completed all 56 checks successfully with four conditional skips, fresh Greptile 5/5 and no open findings, and passed Superagent. A fifth migration-metadata collision arrived before the final Runner job finished. Those green results are preserved as prior-head evidence, not current acceptance. - Previous fully verified migration head: `0e6ef7c44696a377f35e69521b93587576374bd0`, integrating master `f8226b051` after the fourth migration-number collision. Master added terminal GitHub-response migration 0401 while the final native check ran; upstream 0401 SQL/journal/snapshot is preserved unchanged, and preferences are generated as idempotent `0402_yielding_blink.sql` on top. Exactly the five newest snapshots remain. GitHub reports MERGEABLE after the push. All 1,155 relevant migration/review/interaction/native-execution/remote-assets tests, workspace typecheck/build, migration guards and whitespace checks pass. Fresh exact-head CI/Greptile/Superagent are pending. - Immediately preceding head `36c67cf03` completed all 56 checks successfully with four conditional skips, fresh Greptile 5/5 and no open findings, plus passed Superagent security. No retry was needed for that head's CI. Master advanced and introduced only a migration-metadata conflict while its final native check ran, so that green result does not establish acceptance of the current integration. Four consecutive collisions (0398, 0399, 0400, 0401) during verification are retained as concrete integration churn; a brief pause of competing migration merges was requested, without assuming it happened or changing any repo setting. The PR has not been merged. - Prior fully verified head: `36c67cf03`, on the clean `30059c0ed` integration of master `efd65c112`. The last Greptile P2 is fixed: Needs attention retains the participant-agent scope through authoritative server membership queries for each pending task, including historical participation when another agent currently owns the task. It does not approximate participation with current assignment or enumerate every company task. The actual view-switch regression and all 31 focused task/routing/collection tests, UI typecheck/build, token gates and whitespace checks pass. Greptile completed a fresh 5/5 review on this exact head (review 19) with zero new comments and no open review threads. CI and Superagent remain in progress; no current-head CI failure has been reported. - Head `30059c0ed` received Greptile 5/5 with that one nonblocking participant finding, now fixed. Superagent passed that head. Current-base integration restored the ephemeral RunsOn fleet after a superseded workflow retained queued aggregate jobs: normal cancellation was insufficient; the single force-cancel request was interrupted but readback confirmed that obsolete run cancelled and current CI began on the trusted fleet. No cancellation retry or runner/security/workflow changes were made. Its partial CI was superseded by the necessary participant fix and is not current acceptance. - Previous compatibility/base-sync head: `30059c0ededc0eab4e04e8299a884f36db8b292f`, integrating master `efd65c112` and the Inbox/workspace compatibility fixes. GitHub reports MERGEABLE after the push. Coordinator keeps Inbox standalone under More without rewriting the instance's Combined Inbox preference; opting out restores that preference. Saved merged-Inbox URLs resolve to their original owner, task status presets remain usable, and canonical repeated `workspace` parameters retain scoped, deduplicated pagination (including Needs attention). All 192 broader routing/Inbox/sidebar/privacy/review tests and the final 16 task-page tests pass, as do workspace typecheck/build, token gates and whitespace checks. The two review threads are resolved. Fresh exact-head CI, Greptile and Superagent results are pending. - The preceding `d5b9be5bd` remained mergeable but its CI routing gate intentionally fell back to GitHub-hosted runners: “stacked base merge commit does not contain the current base branch.” All 45 downstream jobs stayed queued. Current master was merged cleanly to refresh the pinned base; no runner/security settings or workflow logic were changed. Greptile scored that preceding head 4/5 with the two compatibility findings fixed here. Its automatic Superagent scan was inconclusive at checkout; the documented retry was still pending when superseded. Those results do not establish acceptance of this current head. - Previous approval/migration head: `d5b9be5bd3b8d6b0437e42bf48138377f34fa67d`, integrating master `ba5bba86d` and the stale-output approval correction. GitHub reports MERGEABLE after the push. The fresh P1 finding is fixed: every submitted item now validates and locks its bound output inside the resolution transaction, whether or not it carries a required-quality-review binding. Real database regressions verify that an advanced document revision or deleted attachment refuses the entire batch without recording approvals, and current ordinary output reviews still resolve. All 272 combined migration/review/interaction tests pass; workspace typecheck/build passed. Fresh exact-head Greptile and CI are pending. The automatic Superagent scan ended inconclusive at checkout; one documented on-demand retry is pending. These are explicit verification limits, not passed security results. The P1 thread is resolved. - Master consumed the next migration slot three times during this verification pass: confirmation recovery took 0398, continuation recovery took 0399, and GitHub acknowledgement recovery took 0400. Each upstream SQL/journal/snapshot is preserved unchanged. Preferences are now generated as idempotent `0401_white_lenny_balinger.sql` from upstream 0400; exactly five snapshots remain, and replay coverage locates this migration by table content. This is recorded concurrent integration churn, not a claim that older green results apply to the latest base. - Head `ed3b68da9` fixed the two history/URL findings with 26 focused regression tests, server/UI typechecks, workspace build and token gates passing. Greptile then scored that head 4/5 with the stale-output P1 corrected in the current head. Its partial CI is superseded, not counted as current acceptance. - Previous filter-fix head: `ed3b68da9`, based on the `8458d39d9` merge of master `063cfd438`. The two fresh Greptile findings are fixed: history outcome filters now match recorded per-item verdicts in SQL before pagination (mixed reviews remain in both applicable outcomes), and same-page URL assignee changes/back/forward navigation update that filter without clearing independent filters. All 26 focused database/navigation/collection tests, server/UI typechecks, workspace build, token gates and whitespace checks pass. The two review threads are resolved. Fresh exact-head CI, Greptile and Superagent results are pending. - The `8458d39d9` migration integration also passed all 328 targeted migration/interaction/confirmation tests, workspace typecheck/build and Superagent security scan. Greptile scored that head 4/5 with the two functional gaps fixed in the current head; those prior-head checks are not current acceptance. - Migration integration head: `8458d39d9721f4cb312c2390070a0609f8cddd1a`, integrating master `063cfd43813cabf18417605fb9eefb9aacf95c8d`. GitHub reports MERGEABLE after the push. Master advanced twice during this conflict-resolution pass: first its confirmation-recovery migration took 0398, then its continuation-recovery migration took 0399. Both upstream migrations and journal entries remain unchanged; Coordinator preferences are now generated as idempotent `0400_sad_guardian.sql` from upstream's latest snapshot. Exactly the five newest snapshots remain. The replay regression now locates the table migration by content instead of hardcoding a renumbered filename. - All 328 targeted question/review/confirmation/migration tests passed again after the second merge. Workspace typecheck/build and fresh exact-head CI/reviews are in progress. The preceding `0c46f46d6` passed workspace typecheck/build and Superagent security scan, but was superseded because master consumed migration 0399 while those checks ran. That prior-head result is not acceptance of the current head. No production settings or runner/security permissions were changed, and the PR has not been merged. - Previous integration head: `0c46f46d6f2b23417ab872ce3cb6ff2673647287`, integrating master `287b04d72f1b9fca1412e05435c75b8302ba0710`. GitHub reports MERGEABLE after the push. The renewed conflicts were `0398_snapshot.json`, `_journal.json`, and the top imports in `issue-thread-interactions.ts`. Upstream's `0398` confirmation-recovery index and journal history are preserved unchanged; Coordinator preferences were regenerated as idempotent `0399_bouncy_miek.sql`, with replay coverage updated and exactly the five newest snapshots retained. Both terminal confirmation notifications and Coordinator task assignment/required review behavior are preserved. - All 328 targeted interaction/review/confirmation/migration tests pass, as do workspace typecheck, map validation and whitespace checks. The workspace build and fresh exact-head CI/Greptile/Superagent checks are pending. The first migration-generation attempt hit the sandbox's local IPC restriction before generating files; the unchanged authorized local generation completed successfully. Prior-head green checks below do not establish acceptance of this merge. - Previous acceptance head: `8b7dcf739ed15afc51351fdfa0e0e5fbc75ab788`, with master `1aa3a21ac` integrated and GitHub reporting MERGEABLE. Greptile completed a fresh 5/5 review on this exact head with zero new actionable findings and no open review threads. The [fresh CI workflow](https://github.com/paperclipai/paperclip/actions/runs/40752864319) completed SUCCESS on attempt 2 at 19:40:08 UTC, including all eight E2E shards. Final completed check set: 55 succeeded, four conditional checks skipped, zero pending/queued/running checks, and one neutral/inconclusive Superagent scan. The auxiliary commitperclip review and isolated native Runner compilation/cache-reuse check both passed. This is not a claim that Superagent produced a passed security verdict. GitHub still reports MERGEABLE against current master `60b8089a44b4eaa37e627adbfdf2f130bda13335`. - Current-head E2E: all eight shards and the aggregate passed, including the previously failing YAML browser journey. Serialized server shard 9/9 failed: the protected YAML projection test exceeded its original 15-second timeout; its late update request then reached the next test's reset mock (the captured payload is the prior test's model/label update). All 88 tests in that exact unchanged file passed locally in 38.03 seconds, with no timeout or assertion changes. The original slow-request cause remains unproven. CI failure log: `/tmp/pr17983-8b7-serialized9.log`; local recovery: `/tmp/pr17983-adapter-yaml-repro.log`. The single bounded unchanged shard rerun passed, and the workflow completed SUCCESS. Rerun log: `/tmp/pr17983-8b7-serialized9-rerun.log`. The initial failure is retained; a passing retry does not establish its original slow-request cause. - Greptile scored preceding head `4873704be` 5/5 with one valid P2: opened task decision records missed company-scoped live invalidation. The current head uses the existing company decision prefix for resolution and effect-completion events, and resets private record payloads on access changes. Its thread is resolved. All 107 focused record/live-update tests, UI typecheck/build, token checks and whitespace checks pass. - CI on `4873704be` failed E2E shard 7/8 in the Advanced YAML lost-creation-response journey: its clipboard helper selected page text starting with “Skip to Main Content” instead of YAML. The exact unchanged journey passed in a disposable local browser instance (1 test, 17.1 seconds). No assertion, timeout or production behavior was weakened; the original failure cause remains unproven. Failure log: `/tmp/pr17983-487-e2e7.log`; local check: `/tmp/pr17983-agent-yaml-repro.log`. The full unchanged Advanced YAML browser suite also passed locally (6 tests, 37.8 seconds). Fresh current-head CI subsequently passed this journey and all other E2E shards. - Both the automatic and one supported on-demand Superagent scan on `4873704be` ended neutral/inconclusive: “Pinned PR scan merge-base checkout failed or timed out.” These do not count as a passed security verdict. The current head's automatic scan and one documented on-demand retry also returned the identical neutral checkout failure, with zero annotations or further diagnostics. This is an external scanner blocker, not a passed security verdict. No security settings or permissions were changed. - Recorded decision links now render and focus the full source-task record in both Classic and Coordinator, including recorded choice, submitted inputs, and the existing effect-result resolver. Fresh terminal records take precedence over older open-decision cache entries. Context and account checks keep mismatched or inaccessible records out of the DOM; no decision-history tab was added to tasks or chat. The shared company live-run feed now drives live-only filtering, including ended runs and terminal tasks with stale run entries. All 213 focused tests, UI typecheck/build, token and whitespace checks pass; the final focus follow-up passed all 188 task-detail tests and UI typecheck. The first new navigation test failed because its test host omitted company context; the host was corrected before the passing run. - Previous integration head: `375c5e89516705050d94bab99336212841b2e6e1` merges master `1aa3a21ac` after four additional upstream commits. The sole conflict was competing imports in `server/src/services/issues.ts`; both quality-review policy and inbound wake-notice notifications are preserved. The PR-specific review import now sits beside execution-policy imports instead of the frequently changing first line. GitHub mergeability is rechecked after the push. - On the combined master tree, workspace typecheck/build, all 153 affected UI tests, map validation, and whitespace checks pass. Four server suites passed 77 tests; the fifth suite's module-import hook timed out while broad verification was running. Its unchanged isolated run passed all 41 tests in 7.2 seconds with the original timeout. This records recovery without claiming the original import-timeout cause is proven. CI on `375c5e895` passed every substantive job, including all eight E2E shards and Superagent; Greptile found the two functional gaps fixed in the current head. A short-lived `669d90876` commit added the main fixes before the focused navigation follow-up. - Immediately preceding head `9d9737b49` completed all 56 checks successfully with four conditional skips, Greptile 5/5 and no open findings, plus a successful Superagent scan. The signoff E2E shard passed on its single accepted rerun; its initial cancellation/timing failure is retained below. Master moved again while those checks ran, requiring this final integration rather than treating the prior head's green result as current acceptance. - Previous head `9d9737b496911a6054d0ea22d1b42b28b115e9ab` includes master `d30c8f42b842327ee1c18c41a2efd6ba623da5cd`; that head was subsequently conflicted by moving master. The master merge preserved durable account-bound writes and Coordinator UX. Workspace typecheck/build, 416 focused UI tests, and 142 affected comment-route tests passed. A stable full UI run on preceding head `938ad1665` passed all 9,833 tests across 836 files; its final full workspace build also passed. - Greptile found nested proposal project inheritance and Classic bound-revision links on `91be74952`. Both were fixed with 142 interaction-route tests, 113 focused UI tests, and UI/server typechecks. Its next review identified a valid draft-preservation bug in the new Classic loading UI. Head `9d9737b49` keeps loading/error notices outside editors and annotations while preserving dirty drafts; 115 focused review UI tests pass, including pending/failed revision loads and continued draft editing. All related threads are resolved. Greptile completed a fresh 5/5 review of `9d9737b496911a6054d0ea22d1b42b28b115e9ab` with no open findings. - CI on `938ad1665` exposed an integration regression with the newly merged durable startup saves: the personal preference gate blocked the task page before its first session lookup. The guarded title editor now mounts while that lookup is pending, with presentation-specific tools still gated. All 216 affected component tests pass. The unchanged five-test `disconnect-write-recovery.spec.ts` browser suite passes against a newly launched disposable local-trusted PostgreSQL instance on port 33219, including the exact failed startup-save journey. No live provider, hosted authentication or production proof is claimed. The helper-owned test server exited and the port has no listener. UI typecheck, built-browser assets, token and whitespace checks pass. - Initial durable-write merge tests found duplicate imports and unscoped draft fixtures, fixed before 416 focused tests passed. A sandbox typecheck could not create its IPC socket; the supported rerun passed. The first revision-link test used a wrong Return to latest button assertion, corrected before the 113-test pass. The startup E2E reproduction initially still failed because an outer shell gate also blocked the page; the subsequent fix passed all five cases without changing the test or its timeouts. A focused loading-header assertion initially used an incorrect fixture title; correcting it preceded the 216-test pass. These failures remain recorded. - Earlier routing gates fell back to GitHub-hosted runners while master was moving or conflicted. Superseded queued runs were cancelled, then the `938ad1665` routing gate explicitly selected the ephemeral RunsOn fleet. Its CI actively executed and completed most tests; only the diagnosed startup E2E shard and aggregate failed, alongside the valid Greptile finding. Auxiliary GitHub-hosted workflows also completed successfully on earlier heads, so queueing alone is not an administrative blocker. Fresh [CI on `9d9737b49`](https://github.com/paperclipai/paperclip/actions/runs/40744012678) is the acceptance target; the previously failing startup E2E shard now passes. A different signoff E2E shard failed during executor resubmission when its fixture run was cancelled as `issue_continuation_waiting_on_review`. The exact unchanged journey passed locally (1 test, 46 seconds), and the preceding head with identical server/signoff code passed that CI shard. This supports a bounded rerun but does not establish the timing failure's root cause. After the workflow finished, GitHub accepted one rerun of that failed shard and its dependent aggregate; both passed, completing all checks on that head. The failure log is retained at `/tmp/pr17983-9d9737-e2e7.log` and the local reproduction at `/tmp/pr17983-signoff-repro.log`. No assertion or timeout was weakened. - On the first merge, full workspace typecheck/build, all 9,622 UI tests, 267 tests across seven affected server suites, two migration checks, map validation, token gates, and the PR diff whitespace check passed. Greptile then found duplicate local task-search filtering and lost completed item-review detail. Both are fixed, with 42 passing focused UI tests and passing UI typecheck/build. - A subsequent Greptile review found attachment confirmations without working links and mobile history without a visible host. Confirmation layouts now derive the authenticated immutable-content endpoint from the attachment ID, and Needs You uses the existing full-screen mobile sheet. All 119 focused tests pass, including missing/stale display URLs, both review layouts, and both mobile history entry buttons. UI typecheck/build and token checks pass. Full workspace typecheck/build also pass after the second master merge. - Greptile gave head22f69c304a 5/5 score with one non-blocking desktop-history reopening finding. Head6dd52b6d3fixed it, passed all nine history tests, UI typecheck/build, token gates, and the full 9,679-test UI suite, then received a fresh 5/5 review with zero open findings. These are prior-head results; review on the master-sync commit is pending. - The chat and workspace CI jobs on head22f69c304were interrupted by runner shutdown signals and cancellation before completing their tests. Their logs do not report failing assertions. Those interrupted jobs are not claimed as passes. The new head starts fresh runs. - The first merge CI had a Slack session-sync idle assertion fail after an unexpected database read. Its exact isolated reproduction passes, and the chat CI shard passed on head618c5a2a3. The original failure's cause remains undetermined. The broader local reproduction, run concurrently with full verification, instead encountered 21 timeout failures across its two test chunks; it is not claimed as a passing full local server run. A concurrent local UI run had eight failures (seven timeouts and one delayed autosave assertion). The complete UI run on the final head passed all 9,679 tests across 830 files with two workers; no test timeout or assertion was weakened. This establishes recovery without claiming that every earlier timeout's cause has been proved. An earlier intermediate UI run also overlapped the second master merge and failed the newly added Advanced-sidebar expectation, so it is not treated as stable-tree evidence. The newly merged UI dependencies initially required refreshing the local install from the upstream package manifest; the generated local lockfile update is not committed, per repository policy. - Superagent returned neutral/inconclusive scans on the preceding heads because its pinned merge-base checkout failed or timed out. The available retry endpoint returned 404. These are not successful security verdicts. The fresh scan on `91be74952` passed (PR scan passed; no suspicious PR changes detected). The `938ad1665` scan again returned neutral/inconclusive with the same pinned merge-base checkout error; the check-suite rerun endpoint returned 404. The fresh `9d9737b49` scan completed successfully at 18:22 UTC with no suspicious changes detected. Earlier neutral scans are not counted as successful security verdicts. - Prior head `57902152ac3218096fca669d33ff18400a2a86ae` passed 9,618 UI tests across 826 files, received Greptile 5/5 with no open findings, and completed CI with 56 passes and four conditional skips. These are historical results, not acceptance of the latest merge commit. - The first full build found generated capability-contract drift. The generated outputs were refreshed. Initial sandbox runs could not open local IPC sockets; verification was rerun with local test access. - Current master was merged. The workspace preference migration was regenerated after the existing migration history. - The UI run first found two outdated expectations: early custom-answer validation and the document access error copy. Both were corrected, and the full UI suite then passed. - The local server run found three failures in unchanged tests. The Muse test passes with Python 3.13 instead of the host's Python 3.9. The two workspace tests pass when their test process uses the expected master default branch. These environment settings do not change application behavior or global machine settings. The unsharded server run was stopped after those reproductions; CI supplies the full sharded server result. No successful full local server run is claimed. - Skill inventory generation needs an external eval checkout, which is absent locally. The existing generator functions refreshed only the live skill inventory and its derived files. The checked-in eval inventory was preserved. The inventory validation gate passes. - Greptile found a dropped MCP review requirement, missing per-item output links, generic item-review summaries, and a history-refresh failure. The fixes have 32 passing database-backed server tests and focused rendered-card tests. A follow-up partial-review finding was corrected by forwarding the canonical mutation status before settling the card. Attachments open through the authenticated immutable-content route in both card layouts. The final focused UI set has 117 passing tests, including these regressions. - Both browser shards that first failed expected a disabled-chat message. The copy was restored, and those shards passed in CI on the next commit. The two journeys also passed locally. One local attempt first timed out before feature disable while waiting for the provider's initial message; its isolated reproduction passed. That earlier timing failure remains recorded and is not presented as an explained infrastructure failure. - Serialized server shard 9 first failed during pnpm setup with ECONNRESET, before dependencies or tests ran. Its single rerun passed setup and all tests. The build and production runtime boot passed on the first attempt, but that run's public-install step timed out during Pi setup. The single build-job rerun passed compilation, runtime boot, and clean public-install verification. Those setup scripts are unchanged by this PR. The successful retry establishes recovery; the earlier Pi timeout's underlying cause remains undetermined. The aggregate CI gate is green: [workflow run](https://github.com/paperclipai/paperclip/actions/runs/40637789695). </details> ## Risks - This touches shared task, interaction, and completion paths. Regression tests must cover classic and experimental behavior, authorization, duplicate answers, and stale review targets. - The additive preference table must migrate before the setting is used. The setting defaults off. - The experiment changes navigation and message presentation. Canonical task and document records remain the source of truth. - The initial task brief and saved plan can repeat information. This change fixes chronology; a broader redundancy policy remains a design decision. ## Model Used OpenAI GPT-6 Codex with reasoning and tool-assisted code editing, tests, and browser verification. Earlier iterations used Codex models in this task; their exact model IDs are not available in the current context. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used - [x] I have checked ROADMAP.md - [x] I have searched GitHub for duplicate or related PRs - [x] I have described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change - [x] I have run relevant tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have documented risks - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open recommendations or follow-ups - [x] I will address review comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
5575 lines
203 KiB
TypeScript
5575 lines
203 KiB
TypeScript
import { notifyChatConfirmationResolution } from "./chat-confirmation-notifications.js";
|
|
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
|
|
import { notifyToolReviewChange } from "./tool-review-work-notifications.js";
|
|
import { normalizeEscapedLineBreaks } from "@paperclipai/shared/validators/text";
|
|
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
|
import { historicalQuestionCondition } from "./issue-question-context.js";
|
|
import {
|
|
currentContinuationOrigins,
|
|
deliveredContinuationCommentIds,
|
|
} from "./execution-continuation.js";
|
|
import { parseQuestionInteractionAnswers } from "./question-interaction-answers.js";
|
|
import { bindQualityReview, requiredQualityReview } from "./required-quality-review.js";
|
|
import { resolveChatTaskAssignment } from "./chat-task-assignment.js";
|
|
import { isUniqueViolation } from "../db-errors.js";
|
|
import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js";
|
|
import { connectionIntentDeliveries } from "@paperclipai/db";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import {
|
|
and,
|
|
asc,
|
|
desc,
|
|
eq,
|
|
gte,
|
|
inArray,
|
|
isNotNull,
|
|
isNull,
|
|
ne,
|
|
or,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
assets,
|
|
chatVoiceSessions,
|
|
authUsers,
|
|
companySecretProposals,
|
|
companies,
|
|
documents,
|
|
heartbeatRuns,
|
|
runIdentityContexts,
|
|
issueComments,
|
|
issueAttachments,
|
|
issueDocuments,
|
|
issueQuestionResponseDeliveries,
|
|
issueThreadInteractions,
|
|
issueThreadInteractionIdempotencyKeys,
|
|
issues,
|
|
toolActionRequests,
|
|
toolOauthStates,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
trackInteractionCreated,
|
|
trackInteractionResolved,
|
|
} from "@paperclipai/shared/telemetry";
|
|
import type {
|
|
AcceptIssueThreadInteraction,
|
|
AskUserQuestionsAnswer,
|
|
AskUserQuestionsInteraction,
|
|
CancelIssueThreadInteraction,
|
|
ConnectionIntentInteraction,
|
|
CreateIssueThreadInteraction,
|
|
CreateIssueThreadInteractionInput,
|
|
InteractionResolverGovernance,
|
|
IssueReviewPolicy,
|
|
IssueThreadInteraction,
|
|
IssueThreadInteractionCanonicalResolverPolicy,
|
|
IssueThreadInteractionEffectiveResolverPolicySource,
|
|
IssueThreadInteractionKind,
|
|
IssueThreadInteractionResolverPolicy,
|
|
IssueThreadInteractionResolverPolicyProvenance,
|
|
RequestCheckboxConfirmationInteraction,
|
|
RequestConfirmationInteraction,
|
|
RequestConfirmationTarget,
|
|
RequestItemVerdictsInteraction,
|
|
RequestItemVerdictsResult,
|
|
RequestItemVerdictsResultItem,
|
|
RejectIssueThreadInteraction,
|
|
SkipIssueThreadInteraction,
|
|
RespondIssueThreadInteraction,
|
|
SuggestTasksInteraction,
|
|
SuggestTasksResultCreatedTask,
|
|
SubmitIssueThreadInteractionVerdicts,
|
|
WithdrawIssueThreadInteraction,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
acceptIssueThreadInteractionSchema,
|
|
askUserQuestionsPayloadSchema,
|
|
askUserQuestionsResultSchema,
|
|
cancelIssueThreadInteractionSchema,
|
|
connectionIntentPayloadSchema,
|
|
connectionIntentResultSchema,
|
|
createIssueThreadInteractionSchema,
|
|
legacyIssueThreadInteractionResolverPolicyAlias,
|
|
normalizeIssueThreadInteractionResolverPolicy,
|
|
rejectIssueThreadInteractionSchema,
|
|
requestCheckboxConfirmationPayloadSchema,
|
|
requestCheckboxConfirmationResultSchema,
|
|
requestConfirmationPayloadSchema,
|
|
requestConfirmationResultSchema,
|
|
requestItemVerdictsPayloadSchema,
|
|
requestItemVerdictsResultSchema,
|
|
skipIssueThreadInteractionSchema,
|
|
suggestTasksPayloadSchema,
|
|
suggestTasksResultSchema,
|
|
submitIssueThreadInteractionVerdictsSchema,
|
|
withdrawIssueThreadInteractionSchema,
|
|
} from "@paperclipai/shared";
|
|
import { z } from "zod";
|
|
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
|
import { getTelemetryClient } from "../telemetry.js";
|
|
import { authorizationService } from "./authorization.js";
|
|
import { isCloudManagedInstance } from "./cloud-instance.js";
|
|
import { resolveDeploymentMode } from "../config-file.js";
|
|
import {
|
|
logActivity,
|
|
publishActivity,
|
|
type ActivityPublication,
|
|
} from "./activity-log.js";
|
|
import { evaluateAgentInvokabilityFromDb } from "./agent-invokability.js";
|
|
import { getNativeReviewAssignment } from "./native-runtime/native-review-participant.js";
|
|
import {
|
|
assertIssueReviewVerdictActorAllowed,
|
|
isIssueReviewVerdictInteraction,
|
|
} from "./issue-review-policy.js";
|
|
import {
|
|
issueService,
|
|
ensureAssignmentIssueAccessGrant,
|
|
readAcceptedPlanConfirmationTarget,
|
|
runWorkspaceIsFinalized,
|
|
} from "./issues.js";
|
|
import { questionResponseDeliveryValues } from "./question-response-delivery.js";
|
|
import { lockAcceptedChatHandoffSource } from "./chat-completion-delivery.js";
|
|
import {
|
|
cancelPendingIssueInteractionChatPublications,
|
|
enqueueIssueInteractionChatPublications,
|
|
enqueueTerminalIssueInteractionChatPublications,
|
|
} from "./chat-interaction-publications.js";
|
|
import {
|
|
assertIssueThreadInteractionResolverAudience,
|
|
canonicalizeStoredResolverPolicy,
|
|
issueThreadInteractionResolutionError,
|
|
type IssueThreadInteractionResolverRestriction,
|
|
} from "./issue-thread-interaction-resolution.js";
|
|
import {
|
|
createPullRequestMergeStateResolver,
|
|
extractGitHubPullRequestReferences,
|
|
setBoundedPullRequestCacheEntry,
|
|
type GitHubPullRequestReference,
|
|
type PullRequestMergeState,
|
|
} from "./github-pull-request-merge.js";
|
|
|
|
export { extractGitHubPullRequestReferences } from "./github-pull-request-merge.js";
|
|
export type { GitHubPullRequestReference } from "./github-pull-request-merge.js";
|
|
|
|
type InteractionActor = {
|
|
identityContextId?: string | null;
|
|
agentId?: string | null;
|
|
runId?: string | null;
|
|
userId?: string | null;
|
|
systemId?: string | null;
|
|
resolverPolicyRestriction?:
|
|
| IssueThreadInteractionCanonicalResolverPolicy
|
|
| IssueThreadInteractionResolverRestriction
|
|
| null;
|
|
suggestedTaskEffectsAuthorized?: boolean;
|
|
resolutionDetails?: Record<string, unknown>;
|
|
};
|
|
|
|
async function assertInteractionRunWriteAllowed(tx: Db, issue: { id: string; companyId: string }, actor: InteractionActor) {
|
|
if (!actor.agentId || !actor.runId) return;
|
|
// Keep the same issue -> run lock order as task mutation and checkout.
|
|
await tx.select({ id: issues.id }).from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId))).for("update");
|
|
await assertAgentRunWriteAllowed(tx, issue.companyId, actor);
|
|
}
|
|
|
|
type CreateInteractionOptions = {
|
|
/** Keep independently owned pending cards actionable. Internal runtime bridges use this. */
|
|
supersedePendingSiblingInteractions?: boolean;
|
|
};
|
|
|
|
type InteractionWakeup = (
|
|
agentId: string,
|
|
options: {
|
|
source: "automation";
|
|
triggerDetail: "system";
|
|
reason: "issue_commented";
|
|
payload: Record<string, unknown>;
|
|
idempotencyKey: string;
|
|
allowRunCoalescing?: boolean;
|
|
requestedByActorType: "system";
|
|
requestedByActorId: string;
|
|
contextSnapshot: Record<string, unknown>;
|
|
},
|
|
) => Promise<unknown>;
|
|
|
|
export type IssueThreadInteractionServiceOptions = {
|
|
resolvePullRequestState?: (
|
|
companyId: string,
|
|
reference: GitHubPullRequestReference,
|
|
) => Promise<PullRequestMergeState>;
|
|
wakeup?: InteractionWakeup;
|
|
pullRequestCacheTtlMs?: number;
|
|
now?: () => Date;
|
|
};
|
|
|
|
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
export type InteractionResolutionMutationOptions = {
|
|
/** Confirmation accept/reject nested in an outer transaction must defer these
|
|
* effects and flush them with the root database only after its commit. */
|
|
deferConfirmationCommitEffects?: (effect: (committedDb: Db) => Promise<void>) => void;
|
|
beforeResolveInTransaction?: (tx: DbTransaction) => Promise<void>;
|
|
afterResolveInTransaction?: (
|
|
tx: DbTransaction,
|
|
interaction: IssueThreadInteraction,
|
|
) => Promise<void>;
|
|
};
|
|
|
|
const GITHUB_PULL_REQUEST_URL_PATTERN =
|
|
/https:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9][0-9]*)/gi;
|
|
const GITHUB_PULL_REQUEST_SHORTHAND_PATTERN =
|
|
/(^|[^A-Za-z0-9_.-])([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#([1-9][0-9]*)\b/g;
|
|
const MERGE_CONFIRMATION_INTENT_PATTERN =
|
|
/^(?:please\s+)?(?:confirm(?:\s+that)?\s+.{0,80}\s+)?(?:merge|merged)\b|\bready\s+to\s+merge\b/i;
|
|
const MERGE_CONFIRMATION_ALLOWED_WORDS = new Set([
|
|
"all",
|
|
"and",
|
|
"approved",
|
|
"are",
|
|
"both",
|
|
"checks",
|
|
"ci",
|
|
"confirm",
|
|
"github",
|
|
"green",
|
|
"is",
|
|
"it",
|
|
"link",
|
|
"linked",
|
|
"links",
|
|
"merge",
|
|
"merged",
|
|
"merging",
|
|
"passed",
|
|
"passing",
|
|
"please",
|
|
"pr",
|
|
"primary",
|
|
"prs",
|
|
"pull",
|
|
"ready",
|
|
"reference",
|
|
"references",
|
|
"request",
|
|
"requests",
|
|
"review",
|
|
"secondary",
|
|
"tests",
|
|
"that",
|
|
"the",
|
|
"these",
|
|
"this",
|
|
"to",
|
|
"url",
|
|
]);
|
|
|
|
function isMergeConfirmationOnlyText(value: string) {
|
|
GITHUB_PULL_REQUEST_URL_PATTERN.lastIndex = 0;
|
|
const withoutUrls = value.replace(
|
|
GITHUB_PULL_REQUEST_URL_PATTERN,
|
|
" pr_reference ",
|
|
);
|
|
GITHUB_PULL_REQUEST_SHORTHAND_PATTERN.lastIndex = 0;
|
|
const withoutReferences = withoutUrls.replace(
|
|
GITHUB_PULL_REQUEST_SHORTHAND_PATTERN,
|
|
(_match, prefix: string) => `${prefix} pr_reference `,
|
|
);
|
|
const normalized = withoutReferences
|
|
.replace(/\b(?:github-)?pr-[1-9][0-9]*\b/gi, " pr_reference ")
|
|
.replace(/[`*_\[\]{}()<>:;,.!?"'=+&|\\/-]+/g, " ")
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!normalized) return true;
|
|
return normalized
|
|
.split(/\s+/)
|
|
.every(
|
|
(word) =>
|
|
word === "pr_reference" || MERGE_CONFIRMATION_ALLOWED_WORDS.has(word),
|
|
);
|
|
}
|
|
|
|
export function getMergeConfirmationPullRequestReferences(
|
|
row: Pick<
|
|
IssueThreadInteractionRow,
|
|
"kind" | "title" | "summary" | "payload"
|
|
>,
|
|
) {
|
|
if (row.kind !== "request_confirmation") return [];
|
|
const payload =
|
|
row.payload &&
|
|
typeof row.payload === "object" &&
|
|
!Array.isArray(row.payload)
|
|
? (row.payload as unknown as Record<string, unknown>)
|
|
: null;
|
|
if (
|
|
!payload ||
|
|
payload.toolAction !== undefined ||
|
|
payload.secretProposal !== undefined
|
|
)
|
|
return [];
|
|
|
|
const target =
|
|
payload.target &&
|
|
typeof payload.target === "object" &&
|
|
!Array.isArray(payload.target)
|
|
? (payload.target as Record<string, unknown>)
|
|
: null;
|
|
// Plan/document confirmations and governed action cards must never inherit
|
|
// merge-confirmation authority merely because their prose links to a PR.
|
|
if (target?.type === "issue_document" || target?.type === "issue_attachment") return [];
|
|
|
|
const intentValues = [
|
|
row.title,
|
|
row.summary,
|
|
payload.prompt,
|
|
payload.acceptLabel,
|
|
];
|
|
const intentText = intentValues
|
|
.filter((value): value is string => typeof value === "string")
|
|
.join("\n");
|
|
if (!MERGE_CONFIRMATION_INTENT_PATTERN.test(intentText)) return [];
|
|
|
|
const trustedTextValues = [
|
|
...intentValues,
|
|
payload.detailsMarkdown,
|
|
target?.key,
|
|
target?.label,
|
|
target?.href,
|
|
];
|
|
// System acceptance is intentionally fail-closed: after replacing recognized
|
|
// PR references, every trusted field must contain merge-only vocabulary. This
|
|
// prevents an otherwise valid merge prompt from smuggling an additional action
|
|
// that a governed-action denylist did not anticipate.
|
|
if (
|
|
!trustedTextValues.every(
|
|
(value) =>
|
|
typeof value !== "string" || isMergeConfirmationOnlyText(value),
|
|
)
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
return extractGitHubPullRequestReferences(trustedTextValues);
|
|
}
|
|
|
|
const ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT =
|
|
"issue_thread_interactions_company_issue_idempotency_uq";
|
|
|
|
type IssueWakeTarget = {
|
|
id: string;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId?: string | null;
|
|
status: string;
|
|
workMode?: string;
|
|
};
|
|
|
|
type ResolvedInteractionResult = {
|
|
interaction: IssueThreadInteraction;
|
|
createdIssues: IssueWakeTarget[];
|
|
continuationIssue?: IssueWakeTarget | null;
|
|
};
|
|
|
|
type IssueThreadInteractionRow = typeof issueThreadInteractions.$inferSelect;
|
|
type IssueTouchDb = Pick<Db, "update">;
|
|
|
|
function isNativeCompletionReview(
|
|
row: Pick<IssueThreadInteractionRow, "kind" | "payload">,
|
|
) {
|
|
if (row.kind !== "request_confirmation") return false;
|
|
const payload =
|
|
row.payload &&
|
|
typeof row.payload === "object" &&
|
|
!Array.isArray(row.payload)
|
|
? (row.payload as unknown as Record<string, unknown>)
|
|
: {};
|
|
const target =
|
|
payload.target &&
|
|
typeof payload.target === "object" &&
|
|
!Array.isArray(payload.target)
|
|
? (payload.target as Record<string, unknown>)
|
|
: {};
|
|
return target.type === "custom" && target.key === "native_completion_review";
|
|
}
|
|
|
|
export const DEFAULT_RESOLVER_POLICY_BY_KIND: Record<
|
|
IssueThreadInteractionKind,
|
|
IssueThreadInteractionCanonicalResolverPolicy
|
|
> = {
|
|
suggest_tasks: "anyone",
|
|
ask_user_questions: "anyone",
|
|
request_confirmation: "anyone",
|
|
request_checkbox_confirmation: "anyone",
|
|
request_item_verdicts: "anyone",
|
|
connection_intent: "human_only",
|
|
};
|
|
|
|
const RESOLVER_POLICY_RESTRICTION_RANK: Record<
|
|
IssueThreadInteractionCanonicalResolverPolicy,
|
|
number
|
|
> = {
|
|
anyone: 0,
|
|
not_creator: 1,
|
|
human_only: 2,
|
|
};
|
|
|
|
export function resolveInteractionPolicy(args: {
|
|
kind: IssueThreadInteractionKind;
|
|
requested?: IssueThreadInteractionResolverPolicy;
|
|
governance: InteractionResolverGovernance;
|
|
hasToolAction: boolean;
|
|
hasSecretProposal?: boolean;
|
|
}) {
|
|
const kindGovernance = args.governance[args.kind];
|
|
const requestedPolicyInput =
|
|
args.requested ??
|
|
kindGovernance?.defaultPolicy ??
|
|
DEFAULT_RESOLVER_POLICY_BY_KIND[args.kind];
|
|
const requestedResolverPolicy =
|
|
normalizeIssueThreadInteractionResolverPolicy(requestedPolicyInput);
|
|
const resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance =
|
|
args.requested === undefined ? "inherited" : "explicit";
|
|
|
|
let effectiveResolverPolicy = requestedResolverPolicy;
|
|
let effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource =
|
|
"requested";
|
|
if (args.hasToolAction || args.hasSecretProposal) {
|
|
effectiveResolverPolicy = "human_only";
|
|
effectiveResolverPolicySource = "governed_action";
|
|
} else if (kindGovernance?.cap) {
|
|
const cap = normalizeIssueThreadInteractionResolverPolicy(
|
|
kindGovernance.cap,
|
|
);
|
|
if (
|
|
RESOLVER_POLICY_RESTRICTION_RANK[cap] >
|
|
RESOLVER_POLICY_RESTRICTION_RANK[effectiveResolverPolicy]
|
|
) {
|
|
effectiveResolverPolicy = cap;
|
|
effectiveResolverPolicySource = "company_cap";
|
|
}
|
|
}
|
|
return {
|
|
requestedResolverPolicy,
|
|
effectiveResolverPolicy,
|
|
resolverPolicyProvenance,
|
|
effectiveResolverPolicySource,
|
|
} as const;
|
|
}
|
|
|
|
function resolverActor(actor: InteractionActor) {
|
|
if (actor.systemId)
|
|
return { type: "system" as const, systemId: actor.systemId };
|
|
if (actor.agentId) {
|
|
return {
|
|
type: "agent" as const,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
};
|
|
}
|
|
if (actor.userId) return { type: "user" as const, userId: actor.userId };
|
|
// Missing principals must fail closed. Internal maintenance paths that are
|
|
// intentionally system-owned provide an explicit systemId.
|
|
return { type: "agent" as const, agentId: null, runId: null };
|
|
}
|
|
|
|
function assertInteractionResolutionAllowed(
|
|
current: IssueThreadInteractionRow,
|
|
actor: InteractionActor,
|
|
) {
|
|
return assertIssueThreadInteractionResolverAudience({
|
|
actor: resolverActor(actor),
|
|
interaction: current,
|
|
additionalRestriction: actor.resolverPolicyRestriction,
|
|
governedAction:
|
|
current.kind === "request_confirmation" &&
|
|
current.payload !== null &&
|
|
typeof current.payload === "object" &&
|
|
(("toolAction" in current.payload &&
|
|
current.payload.toolAction !== undefined) ||
|
|
("secretProposal" in current.payload &&
|
|
current.payload.secretProposal !== undefined)),
|
|
});
|
|
}
|
|
|
|
type IssueResolutionContext = {
|
|
id: string;
|
|
companyId: string;
|
|
status: string;
|
|
executionRunId: string | null;
|
|
workMode: string;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
reviewPolicy: IssueReviewPolicy | null;
|
|
createdByAgentId: string | null;
|
|
createdByUserId: string | null;
|
|
};
|
|
|
|
async function assertRequestConfirmationResolutionAllowedUnderLock(
|
|
tx: Db,
|
|
issue: IssueResolutionContext,
|
|
interaction: IssueThreadInteractionRow,
|
|
actor: InteractionActor,
|
|
) {
|
|
if (isTerminalIssueStatus(issue.status)) {
|
|
throw conflict(
|
|
"Interaction is no longer actionable because the issue is closed",
|
|
);
|
|
}
|
|
|
|
const isReviewVerdict =
|
|
issue.status === "in_review" &&
|
|
isRequestConfirmationLikeKind(interaction.kind) &&
|
|
(await isIssueReviewVerdictInteraction(tx, { issue, interaction }));
|
|
|
|
assertInteractionResolutionAllowed(interaction, actor);
|
|
if (actor.agentId && isNativeCompletionReview(interaction)) {
|
|
const target = (interaction.payload as { target?: { revisionId?: string } }).target;
|
|
if (!actor.runId || !await getNativeReviewAssignment(tx, {
|
|
companyId: issue.companyId, issueId: issue.id, agentId: actor.agentId,
|
|
contextSnapshot: { nativeReviewInteractionId: interaction.id, nativeReviewDecisionId: target?.revisionId },
|
|
actingRunId: actor.runId,
|
|
issueExecutionRunId: issue.executionRunId,
|
|
})) throw conflict("This completion review is no longer current or assigned to this agent.");
|
|
}
|
|
if (!isReviewVerdict) return;
|
|
|
|
const verdictActor = actor.agentId
|
|
? { type: "agent" as const, id: actor.agentId }
|
|
: actor.userId
|
|
? { type: "user" as const, id: actor.userId }
|
|
: null;
|
|
if (!verdictActor) {
|
|
throw forbidden("A review verdict requires an authenticated agent or user");
|
|
}
|
|
await assertIssueReviewVerdictActorAllowed(tx, {
|
|
issue,
|
|
actor: verdictActor,
|
|
});
|
|
}
|
|
|
|
const REQUEST_CONFIRMATION_INTERACTION_KINDS = [
|
|
"request_confirmation",
|
|
"request_checkbox_confirmation",
|
|
] as const;
|
|
type RequestConfirmationLikeKind =
|
|
(typeof REQUEST_CONFIRMATION_INTERACTION_KINDS)[number];
|
|
type RequestConfirmationLikeInteraction =
|
|
RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction;
|
|
|
|
const TARGET_BOUND_INTERACTION_KINDS = [
|
|
...REQUEST_CONFIRMATION_INTERACTION_KINDS,
|
|
"request_item_verdicts",
|
|
] as const;
|
|
type TargetBoundInteractionKind =
|
|
(typeof TARGET_BOUND_INTERACTION_KINDS)[number];
|
|
type TargetBoundInteraction =
|
|
RequestConfirmationLikeInteraction | RequestItemVerdictsInteraction;
|
|
|
|
const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [
|
|
...TARGET_BOUND_INTERACTION_KINDS,
|
|
"ask_user_questions",
|
|
"connection_intent",
|
|
] as const;
|
|
type UserCommentSupersedableKind =
|
|
(typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number];
|
|
type UserCommentSupersedableInteraction =
|
|
| TargetBoundInteraction
|
|
| AskUserQuestionsInteraction
|
|
| ConnectionIntentInteraction;
|
|
|
|
function isRequestConfirmationLikeKind(
|
|
kind: string,
|
|
): kind is RequestConfirmationLikeKind {
|
|
return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(
|
|
kind,
|
|
);
|
|
}
|
|
|
|
function isTargetBoundInteractionKind(
|
|
kind: string,
|
|
): kind is TargetBoundInteractionKind {
|
|
return (TARGET_BOUND_INTERACTION_KINDS as readonly string[]).includes(kind);
|
|
}
|
|
|
|
function isUserCommentSupersedableKind(
|
|
kind: string,
|
|
): kind is UserCommentSupersedableKind {
|
|
return (
|
|
USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS as readonly string[]
|
|
).includes(kind);
|
|
}
|
|
|
|
function isEquivalentCreateRequest(
|
|
row: IssueThreadInteractionRow,
|
|
input: CreateIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
defaultAddresseeUserId: string | null = null,
|
|
) {
|
|
return (
|
|
row.kind === input.kind &&
|
|
row.requestedResolverPolicy === input.resolverPolicy &&
|
|
(row.addresseeAgentId ?? null) === (input.addresseeAgentId ?? null) &&
|
|
(row.addresseeUserId ?? defaultAddresseeUserId) === (input.addresseeUserId ?? defaultAddresseeUserId) &&
|
|
row.continuationPolicy === input.continuationPolicy &&
|
|
(row.sourceCommentId ?? null) === (input.sourceCommentId ?? null) &&
|
|
(row.sourceRunId ?? null) === (input.sourceRunId ?? null) &&
|
|
(row.title ?? null) === (input.title ?? null) &&
|
|
(row.summary ?? null) === (input.summary ?? null) &&
|
|
(row.createdByAgentId ?? null) === (actor.agentId ?? null) &&
|
|
(row.createdByUserId ?? null) === (actor.userId ?? null) &&
|
|
isDeepStrictEqual(row.payload, input.payload)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse a stored interaction `result` blob tolerantly. Rows persisted by older
|
|
* builds can carry a `result` shape that predates the current schema — e.g. a
|
|
* legacy `outcome` value ("withdrawn_by_creator") no longer in the enum.
|
|
* `hydrateInteraction` runs over every row in `listForIssue`, so a hard
|
|
* `.parse()` on one stale row throws and 500s the *entire* issue's interaction
|
|
* list — which bricks both the web thread and plugin consumers such as the
|
|
* Slack gateway's notifier/digest/aging loops (LOOA-629). Degrade an
|
|
* unparseable `result` to `null` (the interaction still lists; a
|
|
* resolved-but-unparseable result is treated as absent) instead of throwing.
|
|
*/
|
|
function parseStoredInteractionResult<S extends z.ZodTypeAny>(
|
|
schema: S,
|
|
raw: unknown,
|
|
row: Pick<IssueThreadInteractionRow, "id" | "kind">,
|
|
): z.infer<S> | null {
|
|
if (raw == null) return null;
|
|
const parsed = schema.safeParse(raw);
|
|
if (parsed.success) return parsed.data;
|
|
console.warn(
|
|
`[paperclip] Dropping unparseable ${row.kind} interaction result for interaction ${row.id}`,
|
|
parsed.error.issues,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
function hydrateInteraction(
|
|
row: IssueThreadInteractionRow,
|
|
): IssueThreadInteraction {
|
|
const storedRequestedResolverPolicy =
|
|
row.requestedResolverPolicy as IssueThreadInteractionResolverPolicy;
|
|
const storedEffectiveResolverPolicy =
|
|
row.effectiveResolverPolicy as IssueThreadInteractionResolverPolicy;
|
|
const resolverPolicyProvenance =
|
|
row.resolverPolicyProvenance ??
|
|
(storedRequestedResolverPolicy === "board_only" ||
|
|
storedRequestedResolverPolicy === "board_or_agents"
|
|
? "legacy_inherited_restriction"
|
|
: "inherited");
|
|
const canonicalizeStoredPolicy = (
|
|
policy: IssueThreadInteractionResolverPolicy,
|
|
): IssueThreadInteractionCanonicalResolverPolicy =>
|
|
canonicalizeStoredResolverPolicy(policy, resolverPolicyProvenance);
|
|
const requestedResolverPolicy = canonicalizeStoredPolicy(
|
|
storedRequestedResolverPolicy,
|
|
);
|
|
const effectiveResolverPolicy = canonicalizeStoredPolicy(
|
|
storedEffectiveResolverPolicy,
|
|
);
|
|
const base = {
|
|
...row,
|
|
idempotencyKey: row.idempotencyKey ?? null,
|
|
addresseeAgentId: row.addresseeAgentId ?? null,
|
|
addresseeUserId: row.addresseeUserId ?? null,
|
|
status: row.status as IssueThreadInteraction["status"],
|
|
continuationPolicy:
|
|
row.continuationPolicy as IssueThreadInteraction["continuationPolicy"],
|
|
resolverPolicy: requestedResolverPolicy,
|
|
requestedResolverPolicy,
|
|
effectiveResolverPolicy,
|
|
resolverPolicyProvenance,
|
|
effectiveResolverPolicySource:
|
|
row.effectiveResolverPolicySource ?? "requested",
|
|
legacyResolverPolicyAliases: {
|
|
requested: legacyIssueThreadInteractionResolverPolicyAlias(
|
|
requestedResolverPolicy,
|
|
),
|
|
effective: legacyIssueThreadInteractionResolverPolicyAlias(
|
|
effectiveResolverPolicy,
|
|
),
|
|
},
|
|
};
|
|
|
|
switch (row.kind) {
|
|
case "suggest_tasks":
|
|
return {
|
|
...base,
|
|
kind: "suggest_tasks",
|
|
payload: suggestTasksPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(
|
|
suggestTasksResultSchema,
|
|
row.result,
|
|
row,
|
|
),
|
|
} satisfies SuggestTasksInteraction;
|
|
case "ask_user_questions":
|
|
return {
|
|
...base,
|
|
kind: "ask_user_questions",
|
|
payload: askUserQuestionsPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(
|
|
askUserQuestionsResultSchema,
|
|
row.result,
|
|
row,
|
|
),
|
|
} satisfies AskUserQuestionsInteraction;
|
|
case "request_confirmation":
|
|
return {
|
|
...base,
|
|
kind: "request_confirmation",
|
|
payload: requestConfirmationPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(
|
|
requestConfirmationResultSchema,
|
|
row.result,
|
|
row,
|
|
),
|
|
} satisfies RequestConfirmationInteraction;
|
|
case "request_checkbox_confirmation":
|
|
return {
|
|
...base,
|
|
kind: "request_checkbox_confirmation",
|
|
payload: requestCheckboxConfirmationPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(
|
|
requestCheckboxConfirmationResultSchema,
|
|
row.result,
|
|
row,
|
|
),
|
|
} satisfies RequestCheckboxConfirmationInteraction;
|
|
case "request_item_verdicts":
|
|
return {
|
|
...base,
|
|
kind: "request_item_verdicts",
|
|
payload: requestItemVerdictsPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(
|
|
requestItemVerdictsResultSchema,
|
|
row.result,
|
|
row,
|
|
),
|
|
} satisfies RequestItemVerdictsInteraction;
|
|
case "connection_intent":
|
|
return {
|
|
...base,
|
|
kind: "connection_intent",
|
|
payload: connectionIntentPayloadSchema.parse(row.payload),
|
|
result: parseStoredInteractionResult(
|
|
connectionIntentResultSchema,
|
|
row.result,
|
|
row,
|
|
),
|
|
} satisfies ConnectionIntentInteraction;
|
|
default:
|
|
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
|
|
}
|
|
}
|
|
|
|
async function touchIssue(db: IssueTouchDb, issueId: string) {
|
|
await db
|
|
.update(issues)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(issues.id, issueId));
|
|
}
|
|
|
|
function isTerminalIssueStatus(status: string) {
|
|
return status === "done" || status === "cancelled";
|
|
}
|
|
|
|
function interactionNotFoundError() {
|
|
return notFound("Interaction not found", { code: "interaction_not_found" });
|
|
}
|
|
|
|
function interactionIssueClosedError() {
|
|
return issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_issue_closed",
|
|
"Interaction is no longer actionable because the issue is closed",
|
|
);
|
|
}
|
|
|
|
function interactionAlreadyResolvedError() {
|
|
return issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
|
|
function interactionTerminalError(row: { status: string; result?: unknown }) {
|
|
const result =
|
|
row.result && typeof row.result === "object" && !Array.isArray(row.result)
|
|
? (row.result as unknown as Record<string, unknown>)
|
|
: null;
|
|
if (result?.outcome === "stale_target") {
|
|
return issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_stale_target",
|
|
"Interaction target is stale",
|
|
);
|
|
}
|
|
if (
|
|
result?.outcome === "superseded_by_comment" ||
|
|
result?.outcome === "superseded_by_newer_request"
|
|
) {
|
|
return issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_superseded",
|
|
"Interaction has been superseded",
|
|
);
|
|
}
|
|
if (result?.outcome === "issue_closed") return interactionIssueClosedError();
|
|
return issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
|
|
function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
|
|
issue: IssueResolutionContext;
|
|
current: IssueThreadInteractionRow;
|
|
actor: InteractionActor;
|
|
}) {
|
|
if (!isRequestConfirmationLikeKind(args.current.kind)) return false;
|
|
if (!args.current.createdByAgentId) return false;
|
|
if (!args.actor.userId) return false;
|
|
if (isTerminalIssueStatus(args.issue.status)) return false;
|
|
if (args.issue.assigneeAgentId) {
|
|
return (
|
|
args.issue.status === "in_review" &&
|
|
args.issue.assigneeAgentId === args.current.createdByAgentId
|
|
);
|
|
}
|
|
return Boolean(args.issue.assigneeUserId);
|
|
}
|
|
|
|
function shouldSupersedeInteractionOnUserComment(interaction: UserCommentSupersedableInteraction) {
|
|
if (interaction.kind === "connection_intent") return false;
|
|
if (interaction.kind === "request_confirmation" && interaction.payload.toolAction) return false;
|
|
return interaction.payload.supersedeOnUserComment === true;
|
|
}
|
|
|
|
function normalizeCreateInteractionInput(
|
|
input: CreateIssueThreadInteraction,
|
|
): CreateIssueThreadInteraction {
|
|
switch (input.kind) {
|
|
case "ask_user_questions":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false,
|
|
},
|
|
};
|
|
case "request_confirmation":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false,
|
|
},
|
|
};
|
|
case "request_checkbox_confirmation":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false,
|
|
},
|
|
};
|
|
case "request_item_verdicts":
|
|
return {
|
|
...input,
|
|
payload: {
|
|
...input.payload,
|
|
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false,
|
|
},
|
|
};
|
|
default:
|
|
return input;
|
|
}
|
|
}
|
|
|
|
function buildSupersededByCommentResult(
|
|
row: IssueThreadInteractionRow,
|
|
commentId: string,
|
|
) {
|
|
if (row.kind === "connection_intent") {
|
|
return {
|
|
version: 1,
|
|
outcome: "expired",
|
|
reason: "Superseded by a newer user comment",
|
|
} as const;
|
|
}
|
|
if (row.kind === "ask_user_questions") {
|
|
return {
|
|
version: 1,
|
|
answers: [],
|
|
expirationReason: "superseded_by_comment",
|
|
commentId,
|
|
summaryMarkdown: null,
|
|
} as const;
|
|
}
|
|
|
|
if (row.kind === "request_item_verdicts") {
|
|
const interaction = hydrateInteraction(
|
|
row,
|
|
) as RequestItemVerdictsInteraction;
|
|
return {
|
|
version: 1,
|
|
outcome: "superseded_by_comment",
|
|
complete: false,
|
|
items: interaction.result?.items ?? [],
|
|
commentId,
|
|
} satisfies RequestItemVerdictsResult;
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
outcome: "superseded_by_comment",
|
|
commentId,
|
|
} as const;
|
|
}
|
|
|
|
function buildStaleTargetResult(
|
|
row: IssueThreadInteractionRow,
|
|
staleTarget: RequestConfirmationTarget | null,
|
|
) {
|
|
if (row.kind === "request_item_verdicts") {
|
|
const interaction = hydrateInteraction(
|
|
row,
|
|
) as RequestItemVerdictsInteraction;
|
|
return {
|
|
version: 1,
|
|
outcome: "stale_target",
|
|
complete: false,
|
|
items: interaction.result?.items ?? [],
|
|
staleTarget,
|
|
} satisfies RequestItemVerdictsResult;
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
outcome: "stale_target",
|
|
staleTarget,
|
|
} as const;
|
|
}
|
|
|
|
function buildSupersededByNewerRequestResult(replacementInteractionId: string) {
|
|
return {
|
|
version: 1,
|
|
outcome: "superseded_by_newer_request",
|
|
supersededByInteractionId: replacementInteractionId,
|
|
} as const;
|
|
}
|
|
|
|
// An agent that posts a fresh ask_user_questions while its own earlier ones on
|
|
// the same issue are still pending has replaced them — the newer card carries
|
|
// the real ask, so the stale siblings auto-expire (PAP-437). Mirrors the
|
|
// `superseded_by_comment` shape (ask_user_questions results key expiry off
|
|
// `expirationReason`, not `outcome`) so the UI can hide them cleanly.
|
|
function buildSupersededByNewerInteractionResult(
|
|
replacementInteractionId: string,
|
|
) {
|
|
return {
|
|
version: 1,
|
|
answers: [],
|
|
expirationReason: "superseded_by_newer_interaction",
|
|
supersededByInteractionId: replacementInteractionId,
|
|
summaryMarkdown: null,
|
|
} as const;
|
|
}
|
|
|
|
function buildAdministrativeOutcomeResult(
|
|
row: IssueThreadInteractionRow,
|
|
outcome: "withdrawn" | "issue_closed" | "addressee_deleted",
|
|
reason: string | null = null,
|
|
) {
|
|
if (row.kind === "connection_intent") {
|
|
return { version: 1, outcome: "expired", reason } as const;
|
|
}
|
|
if (row.kind === "ask_user_questions") {
|
|
return {
|
|
version: 1,
|
|
outcome,
|
|
reason,
|
|
answers: [],
|
|
summaryMarkdown: null,
|
|
} as const;
|
|
}
|
|
if (row.kind === "request_item_verdicts") {
|
|
const interaction = hydrateInteraction(
|
|
row,
|
|
) as RequestItemVerdictsInteraction;
|
|
return {
|
|
version: 1,
|
|
outcome,
|
|
reason,
|
|
complete: false,
|
|
items: interaction.result?.items ?? [],
|
|
} satisfies RequestItemVerdictsResult;
|
|
}
|
|
return {
|
|
version: 1,
|
|
outcome,
|
|
reason,
|
|
...(linkedSecretProposalId(row)
|
|
? {
|
|
secretProposal: {
|
|
version: 1,
|
|
status: outcome === "withdrawn" ? "withdrawn" : "expired",
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
}
|
|
: {}),
|
|
} as const;
|
|
}
|
|
|
|
function buildSkippedOutcomeResult(
|
|
row: IssueThreadInteractionRow,
|
|
reason: string | null,
|
|
) {
|
|
if (row.kind === "ask_user_questions") {
|
|
return {
|
|
version: 1,
|
|
outcome: "skipped",
|
|
reason,
|
|
answers: [],
|
|
cancelled: true,
|
|
cancellationReason: reason,
|
|
summaryMarkdown: null,
|
|
} as const;
|
|
}
|
|
if (row.kind === "request_item_verdicts") {
|
|
const interaction = hydrateInteraction(
|
|
row,
|
|
) as RequestItemVerdictsInteraction;
|
|
return {
|
|
version: 1,
|
|
outcome: "skipped",
|
|
reason,
|
|
complete: false,
|
|
items: interaction.result?.items ?? [],
|
|
} satisfies RequestItemVerdictsResult;
|
|
}
|
|
if (row.kind === "suggest_tasks") {
|
|
return {
|
|
version: 1,
|
|
outcome: "skipped",
|
|
reason,
|
|
createdTasks: [],
|
|
skippedClientKeys: [],
|
|
} as const;
|
|
}
|
|
return { version: 1, outcome: "skipped", reason } as const;
|
|
}
|
|
|
|
// Rollback sentinel: the interaction was resolved by another actor between the
|
|
// pending-rows read and the conditional update, so the enclosing transaction's
|
|
// tool-action revocation must be undone.
|
|
class InteractionResolvedConcurrentlyError extends Error {
|
|
constructor() {
|
|
super("Interaction was resolved concurrently");
|
|
}
|
|
}
|
|
|
|
// A request_confirmation card can govern a parked tool call via a linked
|
|
// tool_action_requests row. Administrative resolutions (withdraw, terminal-issue
|
|
// expiry) must settle that row too, or the parked call stays approvable under
|
|
// its own one-hour lifecycle after its card is gone.
|
|
async function hasLinkedToolActionRequests(db: Pick<Db, "select">, companyId: string, interactionIds: string[]) {
|
|
if (interactionIds.length === 0) return false;
|
|
const [request] = await db.select({ id: toolActionRequests.id }).from(toolActionRequests)
|
|
.where(and(eq(toolActionRequests.companyId, companyId), inArray(toolActionRequests.interactionId, interactionIds)))
|
|
.limit(1);
|
|
return request !== undefined;
|
|
}
|
|
|
|
async function notifyToolProjectionForInteractions(db: Pick<Db, "select">, companyId: string, interactionIds: string[]) {
|
|
// The persisted link also covers legacy cards without current payload flags.
|
|
if (await hasLinkedToolActionRequests(db, companyId, interactionIds))
|
|
await notifyDeliveryWork(db, DELIVERY_QUEUES.toolReviewProjection, companyId);
|
|
}
|
|
|
|
async function resolveLinkedToolActionRequests(
|
|
db: Pick<Db, "select" | "update">,
|
|
interaction: Pick<IssueThreadInteractionRow, "id" | "companyId" | "kind">,
|
|
outcome: {
|
|
status: "expired" | "cancelled";
|
|
fromStatuses: Array<"pending" | "approved">;
|
|
actor: InteractionActor;
|
|
now: Date;
|
|
},
|
|
) {
|
|
if (interaction.kind !== "request_confirmation" ||
|
|
!(await hasLinkedToolActionRequests(db, interaction.companyId, [interaction.id]))) return;
|
|
await notifyToolReviewChange(db, interaction.companyId);
|
|
await db
|
|
.update(toolActionRequests)
|
|
.set({
|
|
status: outcome.status,
|
|
resolvedByAgentId: outcome.actor.agentId ?? null,
|
|
resolvedByUserId: outcome.actor.userId ?? null,
|
|
resolvedAt: outcome.now,
|
|
updatedAt: outcome.now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.companyId, interaction.companyId),
|
|
eq(toolActionRequests.interactionId, interaction.id),
|
|
inArray(toolActionRequests.status, outcome.fromStatuses),
|
|
),
|
|
);
|
|
}
|
|
|
|
function linkedSecretProposalId(
|
|
interaction: Pick<IssueThreadInteractionRow, "kind" | "payload">,
|
|
) {
|
|
if (interaction.kind !== "request_confirmation") return null;
|
|
const payload =
|
|
interaction.payload &&
|
|
typeof interaction.payload === "object" &&
|
|
!Array.isArray(interaction.payload)
|
|
? (interaction.payload as unknown as Record<string, unknown>)
|
|
: null;
|
|
const secretProposal =
|
|
payload?.secretProposal &&
|
|
typeof payload.secretProposal === "object" &&
|
|
!Array.isArray(payload.secretProposal)
|
|
? (payload.secretProposal as Record<string, unknown>)
|
|
: null;
|
|
return typeof secretProposal?.proposalId === "string"
|
|
? secretProposal.proposalId
|
|
: null;
|
|
}
|
|
|
|
async function lockLinkedSecretProposal(
|
|
db: Db,
|
|
interaction: Pick<
|
|
IssueThreadInteractionRow,
|
|
"id" | "companyId" | "kind" | "payload"
|
|
>,
|
|
) {
|
|
const proposalId = linkedSecretProposalId(interaction);
|
|
if (!proposalId) return;
|
|
await db
|
|
.select({ id: companySecretProposals.id })
|
|
.from(companySecretProposals)
|
|
.where(
|
|
and(
|
|
eq(companySecretProposals.id, proposalId),
|
|
eq(companySecretProposals.companyId, interaction.companyId),
|
|
eq(companySecretProposals.interactionId, interaction.id),
|
|
),
|
|
)
|
|
.for("update");
|
|
}
|
|
|
|
async function resolveLinkedSecretProposal(
|
|
db: Db,
|
|
interaction: Pick<
|
|
IssueThreadInteractionRow,
|
|
"id" | "companyId" | "kind" | "payload"
|
|
>,
|
|
outcome: {
|
|
status: "rejected" | "withdrawn" | "expired";
|
|
actor: InteractionActor;
|
|
reason?: string | null;
|
|
now: Date;
|
|
},
|
|
) {
|
|
const proposalId = linkedSecretProposalId(interaction);
|
|
if (!proposalId) return;
|
|
await notifyDeliveryWork(db, DELIVERY_QUEUES.secretProposalExpiry, interaction.companyId);
|
|
const [proposal] = await db
|
|
.update(companySecretProposals)
|
|
.set({
|
|
status: outcome.status,
|
|
resolvedByUserId: outcome.actor.userId ?? null,
|
|
resolvedAt: outcome.now,
|
|
resolutionReason: outcome.reason ?? null,
|
|
valueCiphertext: null,
|
|
ciphertextScrubbedAt: outcome.now,
|
|
updatedAt: outcome.now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(companySecretProposals.id, proposalId),
|
|
eq(companySecretProposals.companyId, interaction.companyId),
|
|
eq(companySecretProposals.interactionId, interaction.id),
|
|
eq(companySecretProposals.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!proposal) throw conflict("Linked secret proposal is no longer pending");
|
|
const actorType = outcome.actor.userId
|
|
? ("user" as const)
|
|
: outcome.actor.agentId
|
|
? ("agent" as const)
|
|
: ("system" as const);
|
|
const actorId =
|
|
outcome.actor.userId ??
|
|
outcome.actor.agentId ??
|
|
outcome.actor.systemId ??
|
|
"system";
|
|
await logActivity(db, {
|
|
companyId: interaction.companyId,
|
|
actorType,
|
|
actorId,
|
|
action: `secret.proposal.${outcome.status}`,
|
|
entityType: "company_secret_proposal",
|
|
entityId: proposal.id,
|
|
agentId: proposal.proposedByAgentId,
|
|
runId: proposal.originRunId,
|
|
details: {
|
|
ciphertextScrubbed: true,
|
|
issueId: proposal.originIssueId,
|
|
interactionId: interaction.id,
|
|
reason: outcome.reason ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
function resolveActorKind(
|
|
interaction: Pick<
|
|
IssueThreadInteraction,
|
|
"resolvedByAgentId" | "resolvedByUserId"
|
|
>,
|
|
) {
|
|
if (interaction.resolvedByAgentId) return "agent";
|
|
if (interaction.resolvedByUserId) return "user";
|
|
return "system";
|
|
}
|
|
|
|
function resolveCreatorKind(
|
|
interaction: Pick<
|
|
IssueThreadInteraction,
|
|
"createdByAgentId" | "createdByUserId"
|
|
>,
|
|
) {
|
|
if (interaction.createdByAgentId) return "agent";
|
|
if (interaction.createdByUserId) return "user";
|
|
return undefined;
|
|
}
|
|
|
|
function deriveTargetType(interaction: IssueThreadInteraction) {
|
|
switch (interaction.kind) {
|
|
case "request_confirmation":
|
|
case "request_checkbox_confirmation":
|
|
case "request_item_verdicts":
|
|
return interaction.payload.target?.type ?? "none";
|
|
default:
|
|
return "none";
|
|
}
|
|
}
|
|
|
|
function deriveResolutionReason(interaction: IssueThreadInteraction) {
|
|
switch (interaction.status) {
|
|
case "accepted":
|
|
return "accepted";
|
|
case "rejected":
|
|
return "rejected";
|
|
case "cancelled":
|
|
if (
|
|
interaction.result &&
|
|
"outcome" in interaction.result &&
|
|
interaction.result.outcome === "skipped"
|
|
) {
|
|
return "skipped";
|
|
}
|
|
return "cancelled";
|
|
case "expired": {
|
|
if (interaction.kind === "connection_intent") {
|
|
return interaction.result?.outcome ?? "expired";
|
|
}
|
|
if (interaction.kind === "ask_user_questions") {
|
|
return interaction.result?.expirationReason ?? "expired";
|
|
}
|
|
if (
|
|
interaction.kind === "request_confirmation" ||
|
|
interaction.kind === "request_checkbox_confirmation"
|
|
) {
|
|
return interaction.result?.outcome ?? "expired";
|
|
}
|
|
if (interaction.kind === "request_item_verdicts") {
|
|
return interaction.result?.outcome ?? "expired";
|
|
}
|
|
return "expired";
|
|
}
|
|
case "answered": {
|
|
if (interaction.kind === "request_item_verdicts") {
|
|
return interaction.result?.outcome ?? "answered";
|
|
}
|
|
return "answered";
|
|
}
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function nonNegativeInteger(value: number) {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.max(0, Math.trunc(value));
|
|
}
|
|
|
|
function buildInteractionResolvedCounts(
|
|
interaction: IssueThreadInteraction,
|
|
args?: {
|
|
createdTaskCount?: number;
|
|
},
|
|
) {
|
|
switch (interaction.kind) {
|
|
case "suggest_tasks":
|
|
return {
|
|
createdTaskCount: nonNegativeInteger(args?.createdTaskCount ?? 0),
|
|
skippedTaskCount: nonNegativeInteger(
|
|
interaction.result?.skippedClientKeys?.length ?? 0,
|
|
),
|
|
};
|
|
case "request_checkbox_confirmation":
|
|
return {
|
|
optionCount: nonNegativeInteger(interaction.payload.options.length),
|
|
selectedOptionCount: nonNegativeInteger(
|
|
interaction.result?.selectedOptionIds?.length ?? 0,
|
|
),
|
|
};
|
|
case "ask_user_questions":
|
|
return {
|
|
questionCount: nonNegativeInteger(interaction.payload.questions.length),
|
|
answeredQuestionCount: nonNegativeInteger(
|
|
interaction.result?.answers?.length ?? 0,
|
|
),
|
|
};
|
|
case "request_item_verdicts":
|
|
return {
|
|
itemCount: nonNegativeInteger(interaction.payload.items.length),
|
|
resolvedItemCount: nonNegativeInteger(
|
|
interaction.result?.items?.length ?? 0,
|
|
),
|
|
};
|
|
default:
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function fetchCreatorAgentRoleById(
|
|
db: Pick<Db, "select">,
|
|
interactions: readonly IssueThreadInteraction[],
|
|
) {
|
|
const creatorAgentIds = [
|
|
...new Set(
|
|
interactions
|
|
.map((interaction) => interaction.createdByAgentId)
|
|
.filter((value): value is string => Boolean(value)),
|
|
),
|
|
];
|
|
if (creatorAgentIds.length === 0) return new Map<string, string | null>();
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: agents.id,
|
|
role: agents.role,
|
|
})
|
|
.from(agents)
|
|
.where(inArray(agents.id, creatorAgentIds));
|
|
|
|
return new Map(rows.map((row) => [row.id, row.role] as const));
|
|
}
|
|
|
|
async function emitInteractionResolvedTelemetry(
|
|
db: Pick<Db, "select">,
|
|
interaction: IssueThreadInteraction,
|
|
args?: {
|
|
createdTaskCount?: number;
|
|
creatorRoleByAgentId?: ReadonlyMap<string, string | null>;
|
|
},
|
|
) {
|
|
const telemetryClient = getTelemetryClient();
|
|
if (!telemetryClient) return;
|
|
|
|
try {
|
|
let roleByAgentId =
|
|
args?.creatorRoleByAgentId ?? new Map<string, string | null>();
|
|
if (!args?.creatorRoleByAgentId) {
|
|
try {
|
|
roleByAgentId = await fetchCreatorAgentRoleById(db, [interaction]);
|
|
} catch (error) {
|
|
console.error(
|
|
"[paperclip] Failed to load interaction.resolved creator role",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
const creatorAgentRole = interaction.createdByAgentId
|
|
? (roleByAgentId.get(interaction.createdByAgentId) ?? undefined)
|
|
: undefined;
|
|
|
|
trackInteractionResolved(telemetryClient, {
|
|
interactionKind:
|
|
interaction.kind === "connection_intent" ? "other" : interaction.kind,
|
|
status: interaction.status,
|
|
resolvedByKind: resolveActorKind(interaction),
|
|
resolutionReason: deriveResolutionReason(interaction),
|
|
createdByKind: resolveCreatorKind(interaction),
|
|
creatorAgentRole,
|
|
continuationPolicy: interaction.continuationPolicy,
|
|
targetType: deriveTargetType(interaction),
|
|
...buildInteractionResolvedCounts(interaction, {
|
|
createdTaskCount: args?.createdTaskCount,
|
|
}),
|
|
legacyInheritedRestriction:
|
|
interaction.resolverPolicyProvenance === "legacy_inherited_restriction",
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
"[paperclip] Failed to emit interaction.resolved telemetry",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
function emitInteractionCreatedTelemetry(args: {
|
|
interactionKind: IssueThreadInteractionKind;
|
|
usedDeprecatedResolverPolicyAlias: boolean;
|
|
}) {
|
|
const telemetryClient = getTelemetryClient();
|
|
if (!telemetryClient) return;
|
|
|
|
try {
|
|
trackInteractionCreated(telemetryClient, {
|
|
...args,
|
|
interactionKind:
|
|
args.interactionKind === "connection_intent"
|
|
? "other"
|
|
: args.interactionKind,
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
"[paperclip] Failed to emit interaction.created telemetry",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function emitResolvedInteractionsTelemetry(
|
|
db: Pick<Db, "select">,
|
|
interactions: readonly IssueThreadInteraction[],
|
|
) {
|
|
if (interactions.length === 0 || !getTelemetryClient()) return;
|
|
let roleByAgentId = new Map<string, string | null>();
|
|
try {
|
|
roleByAgentId = await fetchCreatorAgentRoleById(db, interactions);
|
|
} catch (error) {
|
|
console.error(
|
|
"[paperclip] Failed to load interaction.resolved creator roles",
|
|
error,
|
|
);
|
|
}
|
|
await Promise.all(
|
|
interactions.map((interaction) =>
|
|
emitInteractionResolvedTelemetry(db, interaction, {
|
|
creatorRoleByAgentId: roleByAgentId,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
function isCommentAtOrAfterInteraction(args: {
|
|
commentCreatedAt: Date | string;
|
|
interactionCreatedAt: Date | string;
|
|
}) {
|
|
const commentCreatedAtMs = new Date(args.commentCreatedAt).getTime();
|
|
const interactionCreatedAtMs = new Date(args.interactionCreatedAt).getTime();
|
|
if (
|
|
!Number.isFinite(commentCreatedAtMs) ||
|
|
!Number.isFinite(interactionCreatedAtMs)
|
|
)
|
|
return false;
|
|
return commentCreatedAtMs >= interactionCreatedAtMs;
|
|
}
|
|
|
|
function buildTaskCreationOrder(
|
|
tasks: ReadonlyArray<SuggestTasksInteraction["payload"]["tasks"][number]>,
|
|
) {
|
|
const taskByClientKey = new Map(
|
|
tasks.map((task) => [task.clientKey, task] as const),
|
|
);
|
|
const ordered: Array<SuggestTasksInteraction["payload"]["tasks"][number]> =
|
|
[];
|
|
const state = new Map<string, "visiting" | "done">();
|
|
|
|
const visit = (clientKey: string) => {
|
|
const currentState = state.get(clientKey);
|
|
if (currentState === "done") return;
|
|
if (currentState === "visiting") {
|
|
throw unprocessable("Suggested tasks contain a parentClientKey cycle");
|
|
}
|
|
|
|
const task = taskByClientKey.get(clientKey);
|
|
if (!task) {
|
|
throw unprocessable(`Unknown parentClientKey: ${clientKey}`);
|
|
}
|
|
|
|
state.set(clientKey, "visiting");
|
|
if (task.parentClientKey) {
|
|
visit(task.parentClientKey);
|
|
}
|
|
state.set(clientKey, "done");
|
|
ordered.push(task);
|
|
};
|
|
|
|
for (const task of tasks) {
|
|
visit(task.clientKey);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
export function resolveSelectedSuggestedTasks(args: {
|
|
interaction: SuggestTasksInteraction;
|
|
selectedClientKeys?: AcceptIssueThreadInteraction["selectedClientKeys"];
|
|
}) {
|
|
const taskByClientKey = new Map(
|
|
args.interaction.payload.tasks.map(
|
|
(task) => [task.clientKey, task] as const,
|
|
),
|
|
);
|
|
const selectedClientKeys =
|
|
args.selectedClientKeys ??
|
|
args.interaction.payload.tasks.map((task) => task.clientKey);
|
|
const selectedClientKeySet = new Set<string>();
|
|
|
|
for (const clientKey of selectedClientKeys) {
|
|
const task = taskByClientKey.get(clientKey);
|
|
if (!task) {
|
|
throw unprocessable(`Unknown suggested task clientKey: ${clientKey}`);
|
|
}
|
|
selectedClientKeySet.add(clientKey);
|
|
}
|
|
|
|
if (selectedClientKeySet.size === 0) {
|
|
throw unprocessable("Select at least one suggested task to accept");
|
|
}
|
|
|
|
for (const clientKey of selectedClientKeySet) {
|
|
let parentClientKey =
|
|
taskByClientKey.get(clientKey)?.parentClientKey ?? null;
|
|
while (parentClientKey) {
|
|
if (!selectedClientKeySet.has(parentClientKey)) {
|
|
throw unprocessable(
|
|
`Suggested task ${clientKey} requires its parent ${parentClientKey} to also be selected`,
|
|
);
|
|
}
|
|
parentClientKey =
|
|
taskByClientKey.get(parentClientKey)?.parentClientKey ?? null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
selectedTasks: args.interaction.payload.tasks.filter((task) =>
|
|
selectedClientKeySet.has(task.clientKey),
|
|
),
|
|
skippedClientKeys: args.interaction.payload.tasks
|
|
.filter((task) => !selectedClientKeySet.has(task.clientKey))
|
|
.map((task) => task.clientKey),
|
|
};
|
|
}
|
|
|
|
function resolveSelectedCheckboxConfirmationOptions(args: {
|
|
interaction: RequestCheckboxConfirmationInteraction;
|
|
selectedOptionIds?: AcceptIssueThreadInteraction["selectedOptionIds"];
|
|
}) {
|
|
const optionIds = new Set(
|
|
args.interaction.payload.options.map((option) => option.id),
|
|
);
|
|
const selectedOptionIds =
|
|
args.selectedOptionIds ??
|
|
args.interaction.payload.defaultSelectedOptionIds ??
|
|
[];
|
|
const selectedOptionIdSet = new Set<string>();
|
|
|
|
for (const optionId of selectedOptionIds) {
|
|
if (!optionIds.has(optionId)) {
|
|
throw unprocessable(
|
|
`Unknown checkbox confirmation optionId: ${optionId}`,
|
|
);
|
|
}
|
|
selectedOptionIdSet.add(optionId);
|
|
}
|
|
|
|
const selectedCount = selectedOptionIdSet.size;
|
|
const minSelected = args.interaction.payload.minSelected ?? 0;
|
|
const maxSelected = args.interaction.payload.maxSelected ?? null;
|
|
if (selectedCount < minSelected) {
|
|
throw unprocessable(
|
|
`Select at least ${minSelected} checkbox confirmation option(s)`,
|
|
);
|
|
}
|
|
if (maxSelected != null && selectedCount > maxSelected) {
|
|
throw unprocessable(
|
|
`Select no more than ${maxSelected} checkbox confirmation option(s)`,
|
|
);
|
|
}
|
|
|
|
return args.interaction.payload.options
|
|
.filter((option) => selectedOptionIdSet.has(option.id))
|
|
.map((option) => option.id);
|
|
}
|
|
|
|
function resolveRequestItemVerdictSubmissions(args: {
|
|
interaction: RequestItemVerdictsInteraction;
|
|
input: SubmitIssueThreadInteractionVerdicts;
|
|
actor: InteractionActor;
|
|
now: Date;
|
|
}) {
|
|
const existingItems = args.interaction.result?.items ?? [];
|
|
const existingById = new Map(
|
|
existingItems.map((item) => [item.id, item] as const),
|
|
);
|
|
const payloadItemIds = new Set(
|
|
args.interaction.payload.items.map((item) => item.id),
|
|
);
|
|
const enabledVerdicts = new Set(
|
|
args.interaction.payload.verdicts ?? ["approve", "reject"],
|
|
);
|
|
const requireReasonOn = new Set(
|
|
args.interaction.payload.requireReasonOn ?? ["reject"],
|
|
);
|
|
const newlyResolvedById = new Map<string, RequestItemVerdictsResultItem>();
|
|
const newlyResolvedItemIds: string[] = [];
|
|
|
|
for (const submitted of args.input.verdicts) {
|
|
if (!payloadItemIds.has(submitted.id)) {
|
|
throw unprocessable(`Unknown item verdict id: ${submitted.id}`);
|
|
}
|
|
if (existingById.has(submitted.id)) {
|
|
continue;
|
|
}
|
|
if (!enabledVerdicts.has(submitted.verdict)) {
|
|
throw unprocessable(
|
|
`Verdict ${submitted.verdict} is not enabled for this item verdict request`,
|
|
);
|
|
}
|
|
|
|
const reason = submitted.reason?.trim() ?? "";
|
|
if (requireReasonOn.has(submitted.verdict) && reason.length === 0) {
|
|
throw unprocessable(
|
|
`A reason is required when verdict is ${submitted.verdict}`,
|
|
);
|
|
}
|
|
|
|
if (newlyResolvedById.has(submitted.id)) {
|
|
continue;
|
|
}
|
|
newlyResolvedById.set(submitted.id, {
|
|
id: submitted.id,
|
|
verdict: submitted.verdict,
|
|
...(reason ? { reason } : {}),
|
|
...(args.actor.userId ? { resolvedByUserId: args.actor.userId } : {}),
|
|
...(args.actor.agentId ? { resolvedByAgentId: args.actor.agentId } : {}),
|
|
...(args.actor.runId ? { resolvedByRunId: args.actor.runId } : {}),
|
|
resolvedAt: args.now,
|
|
});
|
|
newlyResolvedItemIds.push(submitted.id);
|
|
}
|
|
|
|
const nextById = new Map<string, RequestItemVerdictsResultItem>(
|
|
existingItems.map((item) => [item.id, item]),
|
|
);
|
|
for (const [id, item] of newlyResolvedById) {
|
|
nextById.set(id, item);
|
|
}
|
|
const items = args.interaction.payload.items
|
|
.map((item) => nextById.get(item.id))
|
|
.filter((item): item is RequestItemVerdictsResultItem => Boolean(item));
|
|
|
|
return {
|
|
items,
|
|
complete: items.length === args.interaction.payload.items.length,
|
|
newlyResolvedItemIds,
|
|
};
|
|
}
|
|
|
|
function normalizeQuestionAnswers(args: {
|
|
questions: AskUserQuestionsInteraction["payload"]["questions"];
|
|
textQuestionIds?: ReadonlySet<string>;
|
|
answers: RespondIssueThreadInteraction["answers"];
|
|
}) {
|
|
const questionById = new Map(
|
|
args.questions.map((question) => [question.id, question] as const),
|
|
);
|
|
const answerByQuestionId = new Map<string, AskUserQuestionsAnswer>();
|
|
|
|
for (const answer of args.answers) {
|
|
const question = questionById.get(answer.questionId);
|
|
if (!question) {
|
|
throw unprocessable(`Unknown questionId: ${answer.questionId}`);
|
|
}
|
|
if (answerByQuestionId.has(answer.questionId)) {
|
|
throw unprocessable(
|
|
`Duplicate answer for questionId: ${answer.questionId}`,
|
|
);
|
|
}
|
|
|
|
const uniqueOptionIds = [...new Set(answer.optionIds)];
|
|
const validOptionIds = new Set(question.options.map((option) => option.id));
|
|
for (const optionId of uniqueOptionIds) {
|
|
if (!validOptionIds.has(optionId)) {
|
|
throw unprocessable(
|
|
`Unknown optionId for question ${answer.questionId}: ${optionId}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (question.selectionMode === "single" && uniqueOptionIds.length > 1) {
|
|
throw unprocessable(
|
|
`Question ${answer.questionId} only allows one answer`,
|
|
);
|
|
}
|
|
|
|
// Canonical text fields may contain code or intentional whitespace. The
|
|
// legacy custom-answer path keeps its historical newline/trim behavior.
|
|
const otherText = args.textQuestionIds?.has(answer.questionId)
|
|
? answer.otherText ?? ""
|
|
: normalizeEscapedLineBreaks(answer.otherText ?? "").trim();
|
|
answerByQuestionId.set(answer.questionId, {
|
|
questionId: answer.questionId,
|
|
optionIds: uniqueOptionIds,
|
|
...(otherText ? { otherText } : {}),
|
|
});
|
|
}
|
|
|
|
for (const question of args.questions) {
|
|
const answer = answerByQuestionId.get(question.id);
|
|
if (
|
|
question.required &&
|
|
(!answer || (answer.optionIds.length === 0 && !answer.otherText?.trim()))
|
|
) {
|
|
throw unprocessable(`Question ${question.id} requires an answer`);
|
|
}
|
|
}
|
|
|
|
return args.questions
|
|
.map((question) => answerByQuestionId.get(question.id))
|
|
.filter((answer): answer is AskUserQuestionsAnswer => Boolean(answer));
|
|
}
|
|
|
|
async function getIssueDocumentTargetSnapshot(
|
|
db: Db | any,
|
|
args: {
|
|
companyId: string;
|
|
issueId: string;
|
|
target: RequestConfirmationTarget;
|
|
// When true, take a FOR UPDATE row lock on the joined document so a concurrent
|
|
// revision publish (which updates documents.latestRevisionId) must serialize
|
|
// behind the caller's transaction. Only meaningful inside a transaction.
|
|
lockForUpdate?: boolean;
|
|
},
|
|
) {
|
|
if (args.target.type !== "issue_document") return null;
|
|
const targetIssueId = args.target.issueId ?? args.issueId;
|
|
const query = db
|
|
.select({
|
|
issueId: issueDocuments.issueId,
|
|
documentId: issueDocuments.documentId,
|
|
key: issueDocuments.key,
|
|
latestRevisionId: documents.latestRevisionId,
|
|
latestRevisionNumber: documents.latestRevisionNumber,
|
|
})
|
|
.from(issueDocuments)
|
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
|
.where(
|
|
and(
|
|
eq(issueDocuments.companyId, args.companyId),
|
|
eq(issueDocuments.issueId, targetIssueId),
|
|
eq(issueDocuments.key, args.target.key),
|
|
),
|
|
);
|
|
const row = await (
|
|
args.lockForUpdate ? query.for("update", { of: documents }) : query
|
|
).then(
|
|
(
|
|
rows: Array<{
|
|
issueId: string;
|
|
documentId: string;
|
|
key: string;
|
|
latestRevisionId: string | null;
|
|
latestRevisionNumber: number;
|
|
}>,
|
|
) => rows[0] ?? null,
|
|
);
|
|
|
|
if (!row) return null;
|
|
if (args.target.documentId && args.target.documentId !== row.documentId)
|
|
return null;
|
|
return row;
|
|
}
|
|
|
|
async function getIssueAttachmentTargetSnapshot(db: Db | any, args: {
|
|
companyId: string; issueId: string; target: RequestConfirmationTarget; lockForUpdate?: boolean;
|
|
}) {
|
|
if (args.target.type !== "issue_attachment") return null;
|
|
// Approval is scoped to the containing task; an arbitrary private task's
|
|
// attachment cannot be made visible through a confirmation elsewhere.
|
|
if (args.target.issueId && args.target.issueId !== args.issueId) return null;
|
|
const query = db.select({ attachmentId: issueAttachments.id, assetId: assets.id, sha256: assets.sha256 })
|
|
.from(issueAttachments).innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, args.companyId)))
|
|
.where(and(eq(issueAttachments.companyId, args.companyId), eq(issueAttachments.issueId, args.issueId),
|
|
eq(issueAttachments.id, args.target.attachmentId), eq(assets.id, args.target.assetId), eq(assets.sha256, args.target.sha256)));
|
|
const rows = await (args.lockForUpdate ? query.for("update", { of: [issueAttachments, assets] }) : query);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
function buildIssueDocumentTargetFromSnapshot(args: {
|
|
issueId: string;
|
|
snapshot: {
|
|
issueId: string;
|
|
documentId: string;
|
|
key: string;
|
|
latestRevisionId: string | null;
|
|
latestRevisionNumber: number;
|
|
} | null;
|
|
}): RequestConfirmationTarget | null {
|
|
if (!args.snapshot?.latestRevisionId) return null;
|
|
return {
|
|
type: "issue_document",
|
|
issueId: args.snapshot.issueId ?? args.issueId,
|
|
documentId: args.snapshot.documentId,
|
|
key: args.snapshot.key,
|
|
revisionId: args.snapshot.latestRevisionId,
|
|
revisionNumber: args.snapshot.latestRevisionNumber,
|
|
};
|
|
}
|
|
|
|
function buildIssueDocumentTargetFromDocument(args: {
|
|
issueId: string;
|
|
document: {
|
|
id: string;
|
|
key: string;
|
|
latestRevisionId?: string | null;
|
|
latestRevisionNumber?: number | null;
|
|
} | null;
|
|
}): RequestConfirmationTarget | null {
|
|
if (!args.document?.latestRevisionId) return null;
|
|
return {
|
|
type: "issue_document",
|
|
issueId: args.issueId,
|
|
documentId: args.document.id,
|
|
key: args.document.key,
|
|
revisionId: args.document.latestRevisionId,
|
|
revisionNumber: args.document.latestRevisionNumber ?? null,
|
|
};
|
|
}
|
|
|
|
async function assertRequestConfirmationTargetIsCurrent(
|
|
db: Db | any,
|
|
args: {
|
|
companyId: string;
|
|
issueId: string;
|
|
target?: RequestConfirmationTarget | null;
|
|
// Forwarded to getIssueDocumentTargetSnapshot; pass true when validating
|
|
// inside the create transaction so the revision read locks the document row
|
|
// and stays atomic with the interaction insert.
|
|
lockForUpdate?: boolean;
|
|
},
|
|
) {
|
|
if (!args.target) return;
|
|
if (args.target.type === "issue_attachment") {
|
|
if (!await getIssueAttachmentTargetSnapshot(db, { ...args, target: args.target })) {
|
|
throw unprocessable("request_confirmation target must reference the exact attachment bytes on this task");
|
|
}
|
|
return;
|
|
}
|
|
if (args.target.type !== "issue_document") return;
|
|
const snapshot = await getIssueDocumentTargetSnapshot(db, {
|
|
companyId: args.companyId,
|
|
issueId: args.issueId,
|
|
target: args.target,
|
|
lockForUpdate: args.lockForUpdate,
|
|
});
|
|
if (!snapshot || snapshot.latestRevisionId !== args.target.revisionId) {
|
|
throw unprocessable(
|
|
"request_confirmation target must reference the current issue document revision",
|
|
);
|
|
}
|
|
if (
|
|
args.target.revisionNumber &&
|
|
snapshot.latestRevisionNumber !== args.target.revisionNumber
|
|
) {
|
|
throw unprocessable(
|
|
"request_confirmation target revisionNumber must match the current issue document revision",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function expireStaleRequestConfirmationTarget(
|
|
db: Db | any,
|
|
args: {
|
|
row: IssueThreadInteractionRow;
|
|
actor: InteractionActor;
|
|
},
|
|
): Promise<IssueThreadInteraction | null> {
|
|
if (
|
|
!isTargetBoundInteractionKind(args.row.kind) ||
|
|
args.row.status !== "pending"
|
|
)
|
|
return null;
|
|
const interaction = hydrateInteraction(args.row) as TargetBoundInteraction;
|
|
const target = interaction.payload.target ?? null;
|
|
if (!target) return null;
|
|
if (target.type !== "issue_document" && target.type !== "issue_attachment") return null;
|
|
|
|
const snapshot = target.type === "issue_document" ? await getIssueDocumentTargetSnapshot(db, {
|
|
companyId: args.row.companyId,
|
|
issueId: args.row.issueId,
|
|
target,
|
|
}) : null;
|
|
const isCurrent = target.type === "issue_attachment"
|
|
? await getIssueAttachmentTargetSnapshot(db, { companyId: args.row.companyId, issueId: args.row.issueId, target })
|
|
:
|
|
snapshot &&
|
|
snapshot.latestRevisionId === target.revisionId &&
|
|
(!target.revisionNumber ||
|
|
snapshot.latestRevisionNumber === target.revisionNumber);
|
|
if (isCurrent) return null;
|
|
|
|
const now = new Date();
|
|
const currentTarget = buildIssueDocumentTargetFromSnapshot({
|
|
issueId: args.row.issueId,
|
|
snapshot,
|
|
});
|
|
const expireInTransaction = async (tx: Db | any) => {
|
|
await notifyChatConfirmationResolution(tx, args.row);
|
|
if (args.row.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.row);
|
|
const [updated] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
payload: currentTarget
|
|
? {
|
|
...interaction.payload,
|
|
target: currentTarget,
|
|
}
|
|
: interaction.payload,
|
|
result: {
|
|
...buildStaleTargetResult(args.row, target),
|
|
},
|
|
resolvedByAgentId: args.actor.agentId ?? null,
|
|
resolvedByRunId: args.actor.runId ?? null,
|
|
resolvedByUserId: args.actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, args.row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
throw issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
await touchIssue(tx, args.row.issueId);
|
|
const expired = hydrateInteraction(updated);
|
|
await enqueueTerminalIssueInteractionChatPublications(tx, expired);
|
|
return expired;
|
|
};
|
|
|
|
const expired =
|
|
typeof db.transaction === "function"
|
|
? await db.transaction(expireInTransaction)
|
|
: await expireInTransaction(db);
|
|
await emitInteractionResolvedTelemetry(db, expired);
|
|
return expired;
|
|
}
|
|
|
|
export function issueThreadInteractionService(
|
|
db: Db,
|
|
opts: IssueThreadInteractionServiceOptions = {},
|
|
) {
|
|
const pullRequestStateCache = new Map<
|
|
string,
|
|
{ state: PullRequestMergeState; checkedAt: number }
|
|
>();
|
|
const now = opts.now ?? (() => new Date());
|
|
const defaultPullRequestStateResolver = opts.resolvePullRequestState
|
|
? null
|
|
: createPullRequestMergeStateResolver(db);
|
|
|
|
async function resolvePullRequestState(
|
|
companyId: string,
|
|
reference: GitHubPullRequestReference,
|
|
): Promise<PullRequestMergeState> {
|
|
if (opts.resolvePullRequestState)
|
|
return opts.resolvePullRequestState(companyId, reference);
|
|
return defaultPullRequestStateResolver?.(companyId, reference) ?? "unknown";
|
|
}
|
|
|
|
async function resolvePullRequestStates(
|
|
entries: Array<{
|
|
key: string;
|
|
companyId: string;
|
|
reference: GitHubPullRequestReference;
|
|
}>,
|
|
) {
|
|
const states = new Map<string, PullRequestMergeState>();
|
|
const pending = entries.slice();
|
|
const workerCount = Math.min(8, pending.length);
|
|
await Promise.all(
|
|
Array.from({ length: workerCount }, async () => {
|
|
while (pending.length > 0) {
|
|
const entry = pending.shift();
|
|
if (!entry) return;
|
|
const state = await resolvePullRequestState(
|
|
entry.companyId,
|
|
entry.reference,
|
|
);
|
|
states.set(entry.key, state);
|
|
}
|
|
}),
|
|
);
|
|
return states;
|
|
}
|
|
async function getIdempotentInteraction(args: {
|
|
issueId: string;
|
|
companyId: string;
|
|
idempotencyKey: string;
|
|
}, handle: Db = db) {
|
|
const [original] = await handle.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, args.companyId), eq(issueThreadInteractions.issueId, args.issueId),
|
|
eq(issueThreadInteractions.idempotencyKey, args.idempotencyKey),
|
|
)).limit(1);
|
|
if (original) return original;
|
|
const [alias] = await handle.select({ interaction: issueThreadInteractions }).from(issueThreadInteractionIdempotencyKeys)
|
|
.innerJoin(issueThreadInteractions, and(eq(issueThreadInteractions.id, issueThreadInteractionIdempotencyKeys.interactionId),
|
|
eq(issueThreadInteractions.companyId, args.companyId), eq(issueThreadInteractions.issueId, args.issueId)))
|
|
.where(and(eq(issueThreadInteractionIdempotencyKeys.companyId, args.companyId),
|
|
eq(issueThreadInteractionIdempotencyKeys.issueId, args.issueId), eq(issueThreadInteractionIdempotencyKeys.idempotencyKey, args.idempotencyKey))).limit(1);
|
|
return alias?.interaction ?? null;
|
|
}
|
|
|
|
// Readiness is a projection, not durable interaction state. Use the same
|
|
// source-run predicate as accept so another run cannot hold a ready card back.
|
|
async function withAcceptanceReadiness(
|
|
interactions: IssueThreadInteraction[],
|
|
issueWorkspace?: { companyId: string; executionWorkspaceId: string | null },
|
|
): Promise<IssueThreadInteraction[]> {
|
|
const guarded = interactions.filter((interaction) =>
|
|
interaction.status === "pending"
|
|
&& interaction.sourceRunId
|
|
&& (interaction.kind === "request_checkbox_confirmation"
|
|
|| (interaction.kind === "request_confirmation"
|
|
// Tool reviews resolve through their own route while the originating
|
|
// run may be waiting for the user's decision.
|
|
&& !interaction.payload.toolAction)),
|
|
);
|
|
if (guarded.length === 0) return interactions;
|
|
const workspace = issueWorkspace ?? await db
|
|
.select({
|
|
companyId: issues.companyId,
|
|
executionWorkspaceId: issues.executionWorkspaceId,
|
|
})
|
|
.from(issues)
|
|
.where(and(
|
|
eq(issues.id, guarded[0].issueId),
|
|
eq(issues.companyId, guarded[0].companyId),
|
|
))
|
|
.then((rows) => rows[0]);
|
|
if (!workspace?.executionWorkspaceId) return interactions;
|
|
|
|
// Only pending confirmations need checks, once per source run even if that
|
|
// run produced several cards. Historical interactions add no queries.
|
|
const { companyId, executionWorkspaceId } = workspace;
|
|
const sourceRunIds = [...new Set(guarded.map((interaction) => interaction.sourceRunId!))];
|
|
const pendingRunIds = new Set<string>();
|
|
await Promise.all(sourceRunIds.map(async (runId) => {
|
|
if (!await runWorkspaceIsFinalized(db, companyId, executionWorkspaceId, runId)) {
|
|
pendingRunIds.add(runId);
|
|
}
|
|
}));
|
|
const guardedIds = new Set(guarded.map((interaction) => interaction.id));
|
|
return interactions.map((interaction) =>
|
|
guardedIds.has(interaction.id) && pendingRunIds.has(interaction.sourceRunId!)
|
|
? { ...interaction, acceptanceBlocker: "workspace_sync_pending" }
|
|
: interaction,
|
|
);
|
|
}
|
|
|
|
async function getForIssue(
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
) {
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!current ||
|
|
current.companyId !== issue.companyId ||
|
|
current.issueId !== issue.id
|
|
) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
return (await withAcceptanceReadiness([hydrateInteraction(current)]))[0];
|
|
}
|
|
|
|
async function assertIssueWorkspaceFinalizedForAccept(args: {
|
|
db: Pick<Db, "select">;
|
|
issue: { id: string; companyId: string };
|
|
sourceRunId: string | null;
|
|
}) {
|
|
if (!args.sourceRunId) return;
|
|
|
|
const executionWorkspaceId = await args.db
|
|
.select({ executionWorkspaceId: issues.executionWorkspaceId })
|
|
.from(issues)
|
|
.where(eq(issues.id, args.issue.id))
|
|
.then(
|
|
(rows: Array<{ executionWorkspaceId: string | null }>) =>
|
|
rows[0]?.executionWorkspaceId ?? null,
|
|
);
|
|
|
|
if (!executionWorkspaceId) return;
|
|
|
|
// Block only while the source run's worktree sync-back is genuinely still
|
|
// pending or in flight. A finalize that reached a terminal outcome — including
|
|
// a `failed` sync-back or a stale `running` record left by an ended run — is
|
|
// treated as settled by `runWorkspaceIsFinalized`, so a dead run can no longer
|
|
// wedge this confirmation forever.
|
|
const isFinalized = await runWorkspaceIsFinalized(
|
|
args.db,
|
|
args.issue.companyId,
|
|
executionWorkspaceId,
|
|
args.sourceRunId,
|
|
);
|
|
if (isFinalized) return;
|
|
|
|
throw conflict(
|
|
"Cannot accept interaction: the run that created this interaction has not finished syncing its workspace. " +
|
|
"Retry once the local worktree has finished syncing.",
|
|
{ executionWorkspaceId, sourceRunId: args.sourceRunId },
|
|
);
|
|
}
|
|
|
|
async function getPendingInteractionForResolution(args: {
|
|
issue: { id: string; companyId: string; status?: string };
|
|
interactionId: string;
|
|
}) {
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, args.interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!current) throw interactionNotFoundError();
|
|
if (
|
|
current.companyId !== args.issue.companyId ||
|
|
current.issueId !== args.issue.id
|
|
) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (args.issue.status && isTerminalIssueStatus(args.issue.status)) {
|
|
throw interactionIssueClosedError();
|
|
}
|
|
if (current.status !== "pending") {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function assertIssueOpenForInteractionResolution(issue: {
|
|
id: string;
|
|
companyId: string;
|
|
status?: string;
|
|
}) {
|
|
if (issue.status && isTerminalIssueStatus(issue.status)) {
|
|
throw interactionIssueClosedError();
|
|
}
|
|
}
|
|
|
|
async function acceptRequestConfirmation(args: {
|
|
issue: { id: string; companyId: string };
|
|
current: IssueThreadInteractionRow;
|
|
input: AcceptIssueThreadInteraction;
|
|
actor: InteractionActor;
|
|
mutationOptions?: InteractionResolutionMutationOptions;
|
|
}): Promise<{
|
|
interaction: IssueThreadInteraction;
|
|
continuationIssue: IssueWakeTarget | null;
|
|
}> {
|
|
const expired = await expireStaleRequestConfirmationTarget(db, {
|
|
row: args.current,
|
|
actor: args.actor,
|
|
});
|
|
if (expired)
|
|
throw interactionTerminalError({
|
|
status: expired.status,
|
|
result: expired.result,
|
|
});
|
|
|
|
const now = new Date();
|
|
const postCommitActivityPublications: ActivityPublication[] = [];
|
|
const result = await db.transaction(async (tx) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor);
|
|
await notifyChatConfirmationResolution(tx, args.current);
|
|
if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current);
|
|
await args.mutationOptions?.beforeResolveInTransaction?.(tx);
|
|
// Policy mutations and review transitions use the same issue-row lock,
|
|
// so the authoritative review policy and requester are stable through
|
|
// the verdict write. Terminal issue transitions also lock the issue
|
|
// before expiring linked proposals, so keep issue -> proposal ->
|
|
// interaction as the shared lifecycle order.
|
|
const issueContext = await tx
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
status: issues.status,
|
|
executionRunId: issues.executionRunId,
|
|
workMode: issues.workMode,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
reviewPolicy: issues.reviewPolicy,
|
|
createdByAgentId: issues.createdByAgentId,
|
|
createdByUserId: issues.createdByUserId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, args.issue.id))
|
|
.for("update")
|
|
.then((rows: IssueResolutionContext[]) => rows[0] ?? null);
|
|
|
|
if (!issueContext || issueContext.companyId !== args.issue.companyId) {
|
|
throw notFound("Issue not found");
|
|
}
|
|
|
|
await lockLinkedSecretProposal(tx as unknown as Db, args.current);
|
|
|
|
const lockedCurrent = await tx
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, args.current.id))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!lockedCurrent ||
|
|
lockedCurrent.companyId !== args.issue.companyId ||
|
|
lockedCurrent.issueId !== args.issue.id
|
|
) {
|
|
throw notFound("Interaction not found");
|
|
}
|
|
if (lockedCurrent.status !== "pending") {
|
|
throw issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
await assertRequestConfirmationResolutionAllowedUnderLock(
|
|
tx as unknown as Db,
|
|
issueContext,
|
|
lockedCurrent,
|
|
args.actor,
|
|
);
|
|
|
|
const lockedTarget = (hydrateInteraction(lockedCurrent) as RequestConfirmationLikeInteraction).payload.target;
|
|
await assertRequestConfirmationTargetIsCurrent(tx, {
|
|
companyId: args.issue.companyId, issueId: args.issue.id, target: lockedTarget, lockForUpdate: true,
|
|
});
|
|
|
|
const interaction = hydrateInteraction(lockedCurrent);
|
|
const selectedOptionIds =
|
|
interaction.kind === "request_checkbox_confirmation"
|
|
? resolveSelectedCheckboxConfirmationOptions({
|
|
interaction,
|
|
selectedOptionIds: args.input.selectedOptionIds,
|
|
})
|
|
: undefined;
|
|
|
|
if (lockedCurrent.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, lockedCurrent.companyId, [lockedCurrent.id]);
|
|
const [updated] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
result: {
|
|
version: 1,
|
|
outcome: "accepted",
|
|
...(selectedOptionIds ? { selectedOptionIds } : {}),
|
|
},
|
|
resolvedByAgentId: args.actor.agentId ?? null,
|
|
resolvedByRunId: args.actor.runId ?? null,
|
|
resolvedByUserId: args.actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, lockedCurrent.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
throw issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
|
|
const acceptedInteraction = hydrateInteraction(updated);
|
|
await args.mutationOptions?.afterResolveInTransaction?.(
|
|
tx,
|
|
acceptedInteraction,
|
|
);
|
|
|
|
let continuationIssue: IssueWakeTarget | null = null;
|
|
const acceptedPlanTarget = readAcceptedPlanConfirmationTarget(
|
|
lockedCurrent.payload,
|
|
issueContext.id,
|
|
);
|
|
const acceptedPlanStartsExecution =
|
|
lockedCurrent.kind === "request_confirmation" &&
|
|
acceptedPlanTarget?.issueId === issueContext.id &&
|
|
acceptedPlanTarget.key === "plan" &&
|
|
issueContext.workMode === "planning";
|
|
if (isNativeCompletionReview(lockedCurrent)) {
|
|
const otherPending = await tx.select({ id: issueThreadInteractions.id })
|
|
.from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, issueContext.companyId),
|
|
eq(issueThreadInteractions.issueId, issueContext.id),
|
|
ne(issueThreadInteractions.id, lockedCurrent.id),
|
|
or(
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
and(
|
|
ne(issueThreadInteractions.status, "accepted"),
|
|
sql`${issueThreadInteractions.payload}->'target'->>'key' = 'native_completion_review'`,
|
|
sql`${issueThreadInteractions.payload}->'target'->>'revisionId' = ${JSON.stringify(lockedCurrent.payload)}::jsonb->'target'->>'revisionId'`,
|
|
),
|
|
),
|
|
)).limit(1);
|
|
// Each explicit reviewer must be able to answer independently. Completing
|
|
// on the first answer would cancel the other pending decisions.
|
|
const completedIssue = otherPending.length > 0 || issueContext.status !== "in_review" ? null : await issueService(db).update(
|
|
args.issue.id,
|
|
{
|
|
status: "done",
|
|
actorAgentId: args.actor.agentId ?? null,
|
|
actorUserId: args.actor.userId ?? null,
|
|
},
|
|
tx,
|
|
postCommitActivityPublications,
|
|
);
|
|
if (completedIssue) {
|
|
continuationIssue = {
|
|
id: completedIssue.id,
|
|
assigneeAgentId: completedIssue.assigneeAgentId ?? null,
|
|
assigneeUserId: completedIssue.assigneeUserId ?? null,
|
|
status: completedIssue.status,
|
|
};
|
|
}
|
|
} else if (
|
|
shouldReturnAcceptedConfirmationToCreatorAgent({
|
|
issue: issueContext,
|
|
current: lockedCurrent,
|
|
actor: args.actor,
|
|
})
|
|
) {
|
|
const returnStatus =
|
|
issueContext.status === "blocked" ? "blocked" : "todo";
|
|
const returnedIssue = await issueService(db).update(
|
|
args.issue.id,
|
|
{
|
|
status: returnStatus,
|
|
...(acceptedPlanStartsExecution ? { workMode: "standard" } : {}),
|
|
assigneeAgentId: lockedCurrent.createdByAgentId,
|
|
assigneeUserId: null,
|
|
actorAgentId: args.actor.agentId ?? null,
|
|
actorUserId: args.actor.userId ?? null,
|
|
},
|
|
tx,
|
|
postCommitActivityPublications,
|
|
);
|
|
|
|
if (returnedIssue) {
|
|
continuationIssue = {
|
|
id: returnedIssue.id,
|
|
assigneeAgentId: returnedIssue.assigneeAgentId ?? null,
|
|
assigneeUserId: returnedIssue.assigneeUserId ?? null,
|
|
status: returnedIssue.status,
|
|
...(acceptedPlanStartsExecution
|
|
? { workMode: returnedIssue.workMode }
|
|
: {}),
|
|
};
|
|
}
|
|
} else if (acceptedPlanStartsExecution) {
|
|
const executionIssue = await issueService(db).update(
|
|
args.issue.id,
|
|
{
|
|
workMode: "standard",
|
|
actorAgentId: args.actor.agentId ?? null,
|
|
actorUserId: args.actor.userId ?? null,
|
|
},
|
|
tx,
|
|
postCommitActivityPublications,
|
|
);
|
|
if (executionIssue) {
|
|
continuationIssue = {
|
|
id: executionIssue.id,
|
|
assigneeAgentId: executionIssue.assigneeAgentId ?? null,
|
|
assigneeUserId: executionIssue.assigneeUserId ?? null,
|
|
status: executionIssue.status,
|
|
workMode: executionIssue.workMode,
|
|
};
|
|
}
|
|
} else {
|
|
await touchIssue(tx, args.issue.id);
|
|
}
|
|
|
|
// Snapshot the continuation target only after every lifecycle mutation
|
|
// above has settled. Accepted plans and creator-return confirmations can
|
|
// reassign the issue; enqueueing earlier leaves a durable wake addressed
|
|
// to the stale assignee, which the worker must (correctly) discard.
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
acceptedInteraction,
|
|
);
|
|
|
|
if (args.actor.systemId) {
|
|
await logActivity(tx as unknown as Db, {
|
|
companyId: args.issue.companyId,
|
|
actorType: "system",
|
|
actorId: args.actor.systemId,
|
|
agentId: null,
|
|
runId: null,
|
|
action: "issue.thread_interaction_accepted",
|
|
entityType: "issue",
|
|
entityId: args.issue.id,
|
|
details: {
|
|
interactionId: lockedCurrent.id,
|
|
interactionKind: lockedCurrent.kind,
|
|
interactionStatus: "accepted",
|
|
resolutionActorKind: "system",
|
|
requestedResolverPolicy: lockedCurrent.requestedResolverPolicy,
|
|
effectiveResolverPolicy: lockedCurrent.effectiveResolverPolicy,
|
|
...(args.actor.resolutionDetails ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
return {
|
|
interaction: acceptedInteraction,
|
|
continuationIssue,
|
|
};
|
|
});
|
|
const publish = async (committedDb: Db) => {
|
|
for (const publication of postCommitActivityPublications) publishActivity(publication);
|
|
await emitInteractionResolvedTelemetry(committedDb, result.interaction);
|
|
};
|
|
if (args.mutationOptions?.deferConfirmationCommitEffects) args.mutationOptions.deferConfirmationCommitEffects(publish);
|
|
else await publish(db);
|
|
return result;
|
|
}
|
|
|
|
async function rejectRequestConfirmation(args: {
|
|
issue: { id: string; companyId: string };
|
|
current: IssueThreadInteractionRow;
|
|
input: RejectIssueThreadInteraction;
|
|
actor: InteractionActor;
|
|
mutationOptions?: InteractionResolutionMutationOptions;
|
|
}): Promise<IssueThreadInteraction> {
|
|
const expired = await expireStaleRequestConfirmationTarget(db, {
|
|
row: args.current,
|
|
actor: args.actor,
|
|
});
|
|
if (expired)
|
|
throw interactionTerminalError({
|
|
status: expired.status,
|
|
result: expired.result,
|
|
});
|
|
|
|
const interaction = hydrateInteraction(
|
|
args.current,
|
|
) as RequestConfirmationLikeInteraction;
|
|
const reason = args.input.reason?.trim() ?? "";
|
|
if (
|
|
interaction.payload.rejectRequiresReason === true &&
|
|
reason.length === 0
|
|
) {
|
|
throw unprocessable("A decline reason is required for this confirmation");
|
|
}
|
|
|
|
const now = new Date();
|
|
const updated = await db.transaction(async (tx) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor);
|
|
await notifyChatConfirmationResolution(tx, args.current);
|
|
if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current);
|
|
await args.mutationOptions?.beforeResolveInTransaction?.(tx);
|
|
const issueContext = await tx
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
status: issues.status,
|
|
executionRunId: issues.executionRunId,
|
|
workMode: issues.workMode,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
reviewPolicy: issues.reviewPolicy,
|
|
createdByAgentId: issues.createdByAgentId,
|
|
createdByUserId: issues.createdByUserId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, args.issue.id))
|
|
.for("update")
|
|
.then((rows: IssueResolutionContext[]) => rows[0] ?? null);
|
|
if (!issueContext || issueContext.companyId !== args.issue.companyId) {
|
|
throw notFound("Issue not found");
|
|
}
|
|
|
|
// Terminal issue transitions expire linked proposals while holding this
|
|
// issue row. Match their issue -> proposal -> interaction order so a
|
|
// close/cancel race cannot invert the first two locks.
|
|
await lockLinkedSecretProposal(tx as unknown as Db, args.current);
|
|
|
|
const lockedCurrent = await tx
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, args.current.id))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!lockedCurrent ||
|
|
lockedCurrent.companyId !== args.issue.companyId ||
|
|
lockedCurrent.issueId !== args.issue.id
|
|
) {
|
|
throw notFound("Interaction not found");
|
|
}
|
|
if (lockedCurrent.status !== "pending") {
|
|
throw issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
await assertRequestConfirmationResolutionAllowedUnderLock(
|
|
tx as unknown as Db,
|
|
issueContext,
|
|
lockedCurrent,
|
|
args.actor,
|
|
);
|
|
|
|
await resolveLinkedSecretProposal(tx as unknown as Db, lockedCurrent, {
|
|
status: "rejected",
|
|
actor: args.actor,
|
|
reason: reason || null,
|
|
now,
|
|
});
|
|
|
|
if (lockedCurrent.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, lockedCurrent.companyId, [lockedCurrent.id]);
|
|
const [resolved] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "rejected",
|
|
result: {
|
|
version: 1,
|
|
outcome: "rejected",
|
|
reason: reason || null,
|
|
...(linkedSecretProposalId(lockedCurrent)
|
|
? {
|
|
secretProposal: {
|
|
version: 1,
|
|
status: "rejected",
|
|
updatedAt: now.toISOString(),
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
resolvedByAgentId: args.actor.agentId ?? null,
|
|
resolvedByRunId: args.actor.runId ?? null,
|
|
resolvedByUserId: args.actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, lockedCurrent.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
if (!resolved) {
|
|
throw issueThreadInteractionResolutionError(
|
|
409,
|
|
"interaction_already_resolved",
|
|
"Interaction has already been resolved",
|
|
);
|
|
}
|
|
const rejectedInteraction = hydrateInteraction(resolved);
|
|
await args.mutationOptions?.afterResolveInTransaction?.(
|
|
tx,
|
|
rejectedInteraction,
|
|
);
|
|
const rejectedPlanNeedsRevision =
|
|
lockedCurrent.kind === "request_confirmation" &&
|
|
readAcceptedPlanConfirmationTarget(
|
|
lockedCurrent.payload,
|
|
issueContext.id,
|
|
)?.key === "plan";
|
|
const shouldResumeReviewedIssue =
|
|
issueContext.status === "in_review" &&
|
|
(lockedCurrent.continuationPolicy === "wake_assignee" ||
|
|
rejectedPlanNeedsRevision);
|
|
if (
|
|
isNativeCompletionReview(lockedCurrent) ||
|
|
shouldResumeReviewedIssue
|
|
) {
|
|
await issueService(db).update(
|
|
args.issue.id,
|
|
{
|
|
status: "todo",
|
|
assigneeAgentId: issueContext.assigneeAgentId,
|
|
assigneeUserId: null,
|
|
actorAgentId: args.actor.agentId ?? null,
|
|
actorUserId: args.actor.userId ?? null,
|
|
},
|
|
tx,
|
|
);
|
|
} else {
|
|
await touchIssue(tx, args.issue.id);
|
|
}
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
rejectedInteraction,
|
|
);
|
|
return resolved;
|
|
});
|
|
|
|
const rejected = hydrateInteraction(updated);
|
|
const publish = (committedDb: Db) => emitInteractionResolvedTelemetry(committedDb, rejected);
|
|
if (args.mutationOptions?.deferConfirmationCommitEffects) args.mutationOptions.deferConfirmationCommitEffects(publish);
|
|
else await publish(db);
|
|
return rejected;
|
|
}
|
|
|
|
return {
|
|
getForIssue,
|
|
createConnectionIntent: async (
|
|
issue: { id: string; companyId: string },
|
|
input: {
|
|
payload: ConnectionIntentInteraction["payload"];
|
|
sourceRunId: string;
|
|
sourceIdentityContextId?: string | null;
|
|
addresseeUserId: string;
|
|
idempotencyKey: string;
|
|
},
|
|
) => {
|
|
const payload = connectionIntentPayloadSchema.parse(input.payload);
|
|
const sameSavedSetup = (candidate: typeof payload) =>
|
|
candidate.savedSetup?.endpointId === payload.savedSetup?.endpointId
|
|
&& candidate.savedSetup?.connectionId === payload.savedSetup?.connectionId
|
|
&& candidate.savedSetup?.action === payload.savedSetup?.action
|
|
&& Boolean(candidate.savedSetup?.credentialRotation) === Boolean(payload.savedSetup?.credentialRotation)
|
|
&& candidate.savedSetup?.credentialEntry?.requireAll === payload.savedSetup?.credentialEntry?.requireAll
|
|
// Preserve the original generation boundary on retries, while keeping
|
|
// requests for different credential fields distinct.
|
|
&& isDeepStrictEqual(Object.keys(candidate.savedSetup?.credentialEntry?.previousGenerations ?? {}).sort(),
|
|
Object.keys(payload.savedSetup?.credentialEntry?.previousGenerations ?? {}).sort());
|
|
const existing = await getIdempotentInteraction({
|
|
issueId: issue.id,
|
|
companyId: issue.companyId,
|
|
idempotencyKey: input.idempotencyKey,
|
|
});
|
|
if (existing) {
|
|
if (
|
|
existing.kind !== "connection_intent"
|
|
|| existing.sourceRunId !== input.sourceRunId
|
|
|| existing.addresseeUserId !== input.addresseeUserId
|
|
|| (existing.kind === "connection_intent"
|
|
? (connectionIntentPayloadSchema.parse(existing.payload).serviceSlug !== payload.serviceSlug || connectionIntentPayloadSchema.parse(existing.payload).purpose !== payload.purpose || !sameSavedSetup(connectionIntentPayloadSchema.parse(existing.payload)) || !isDeepStrictEqual(connectionIntentPayloadSchema.parse(existing.payload).accessRequest, payload.accessRequest))
|
|
: !isDeepStrictEqual(existing.payload, payload))
|
|
) {
|
|
throw conflict(
|
|
"Interaction idempotency key already exists for a different request",
|
|
{
|
|
idempotencyKey: input.idempotencyKey,
|
|
},
|
|
);
|
|
}
|
|
const interaction = hydrateInteraction(existing) as ConnectionIntentInteraction;
|
|
await enqueueIssueInteractionChatPublications(db, interaction);
|
|
return interaction;
|
|
}
|
|
|
|
let inserted = false;
|
|
const created = await db.transaction(async (tx) => {
|
|
const issueRow = await tx
|
|
.select({ status: issues.status, assigneeAgentId: issues.assigneeAgentId })
|
|
.from(issues)
|
|
.where(
|
|
and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issueRow || isTerminalIssueStatus(issueRow.status) || issueRow.assigneeAgentId !== payload.requestingAgentId) {
|
|
throw conflict("Cannot create an interaction on a closed issue");
|
|
}
|
|
|
|
const [qualityAlias] = await tx.select({ id: issueThreadInteractionIdempotencyKeys.id }).from(issueThreadInteractionIdempotencyKeys)
|
|
.where(and(eq(issueThreadInteractionIdempotencyKeys.companyId, issue.companyId), eq(issueThreadInteractionIdempotencyKeys.issueId, issue.id),
|
|
eq(issueThreadInteractionIdempotencyKeys.idempotencyKey, input.idempotencyKey))).limit(1);
|
|
if (qualityAlias) throw conflict("Interaction idempotency key already exists for a different request", { idempotencyKey: input.idempotencyKey });
|
|
|
|
// Serialize on the task so retries and later runs share the same live card.
|
|
const pending = await tx.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.kind, "connection_intent"),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
eq(issueThreadInteractions.createdByAgentId, payload.requestingAgentId),
|
|
eq(issueThreadInteractions.addresseeUserId, input.addresseeUserId),
|
|
));
|
|
const reusable = pending.find((candidate) =>
|
|
connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug && connectionIntentPayloadSchema.parse(candidate.payload).purpose === payload.purpose && sameSavedSetup(connectionIntentPayloadSchema.parse(candidate.payload)) && isDeepStrictEqual(connectionIntentPayloadSchema.parse(candidate.payload).accessRequest, payload.accessRequest));
|
|
if (reusable) {
|
|
await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(reusable));
|
|
return reusable;
|
|
}
|
|
|
|
const [sourceRun] = await tx.select({ context: heartbeatRuns.contextSnapshot }).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, input.sourceRunId), eq(heartbeatRuns.companyId, issue.companyId)));
|
|
if (!sourceRun) throw unprocessable("Interaction source run is unavailable");
|
|
const originCommentIds = await currentContinuationOrigins(tx as unknown as Db, issue.companyId, issue.id, sourceRun.context);
|
|
const [row] = await tx
|
|
.insert(issueThreadInteractions)
|
|
.values({
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
kind: "connection_intent",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
resolverPolicyProvenance: "explicit",
|
|
effectiveResolverPolicySource: "governed_action",
|
|
idempotencyKey: input.idempotencyKey,
|
|
sourceRunId: input.sourceRunId,
|
|
originCommentIds,
|
|
sourceIdentityContextId: input.sourceIdentityContextId ?? null,
|
|
title: payload.accessRequest ? `Grant ${payload.serviceName} access to ${payload.requestingAgentName}?`
|
|
: payload.savedSetup?.action === "rotate_credentials" ? `Rotate ${payload.serviceName} webhook secret` : `Connect ${payload.serviceName}`,
|
|
summary: payload.savedSetup?.action === "rotate_credentials"
|
|
? `${payload.requestingAgentName} needs you to rotate the signing secret and reconnect the saved App.`
|
|
: `${payload.requestingAgentName} needs this connection to continue.`,
|
|
createdByAgentId: payload.requestingAgentId,
|
|
addresseeUserId: input.addresseeUserId,
|
|
payload,
|
|
})
|
|
.returning();
|
|
|
|
const olderPending = await tx
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.kind, "connection_intent"),
|
|
eq(
|
|
issueThreadInteractions.createdByAgentId,
|
|
payload.requestingAgentId,
|
|
),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
ne(issueThreadInteractions.id, row.id),
|
|
),
|
|
);
|
|
const supersededIds = olderPending
|
|
.filter((candidate) => {
|
|
const candidatePayload = connectionIntentPayloadSchema.safeParse(
|
|
candidate.payload,
|
|
);
|
|
return (
|
|
candidatePayload.success &&
|
|
candidatePayload.data.serviceSlug === payload.serviceSlug && candidatePayload.data.purpose === payload.purpose
|
|
);
|
|
})
|
|
.map((candidate) => candidate.id);
|
|
if (supersededIds.length > 0) {
|
|
await notifyChatConfirmationResolution(tx, { companyId: issue.companyId, issueId: issue.id, kind: "connection_intent" });
|
|
await tx
|
|
.delete(toolOauthStates)
|
|
.where(inArray(toolOauthStates.interactionId, supersededIds));
|
|
const superseded = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "superseded",
|
|
supersededByInteractionId: row.id,
|
|
},
|
|
resolvedAt: now(),
|
|
updatedAt: now(),
|
|
})
|
|
.where(inArray(issueThreadInteractions.id, supersededIds))
|
|
.returning();
|
|
for (const previous of superseded) {
|
|
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(previous));
|
|
}
|
|
}
|
|
await touchIssue(tx, issue.id);
|
|
inserted = true;
|
|
await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(row));
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.connection);
|
|
return row;
|
|
});
|
|
|
|
const interaction = hydrateInteraction(created) as ConnectionIntentInteraction;
|
|
if (inserted) emitInteractionCreatedTelemetry({
|
|
interactionKind: "connection_intent",
|
|
usedDeprecatedResolverPolicyAlias: false,
|
|
});
|
|
return interaction;
|
|
},
|
|
updateConnectionIntentPhase: async (
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
phase: ConnectionIntentInteraction["payload"]["phase"],
|
|
actor: InteractionActor,
|
|
) => {
|
|
const current = await getPendingInteractionForResolution({
|
|
issue,
|
|
interactionId,
|
|
});
|
|
if (current.kind !== "connection_intent") {
|
|
throw unprocessable(
|
|
"Only connection_intent interactions have a connection phase",
|
|
);
|
|
}
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
const payload = connectionIntentPayloadSchema.parse(current.payload);
|
|
const [updated] = await db
|
|
.update(issueThreadInteractions)
|
|
.set({ payload: { ...payload, phase }, updatedAt: now() })
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!updated) throw interactionAlreadyResolvedError();
|
|
await touchIssue(db, issue.id);
|
|
return hydrateInteraction(updated) as ConnectionIntentInteraction;
|
|
},
|
|
resolveConnectionIntent: async (
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
resultInput: ConnectionIntentInteraction["result"] extends infer T
|
|
? NonNullable<T>
|
|
: never,
|
|
actor: InteractionActor,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
const result = connectionIntentResultSchema.parse(resultInput);
|
|
const current = await getPendingInteractionForResolution({
|
|
issue,
|
|
interactionId,
|
|
});
|
|
if (current.kind !== "connection_intent") {
|
|
throw unprocessable(
|
|
"Only connection_intent interactions can be resolved by this operation",
|
|
);
|
|
}
|
|
if (!actor.userId)
|
|
throw forbidden("Connection intents require a human resolver");
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
const status =
|
|
result.outcome === "connected"
|
|
? "accepted"
|
|
: result.outcome === "declined"
|
|
? "rejected"
|
|
: "expired";
|
|
const resolvedAt = now();
|
|
const updated = await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, current);
|
|
await mutationOptions.beforeResolveInTransaction?.(tx);
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status,
|
|
result,
|
|
resolvedByUserId: actor.userId,
|
|
resolvedAt,
|
|
updatedAt: resolvedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!row) throw interactionAlreadyResolvedError();
|
|
if (status === "accepted" || status === "rejected") {
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.connection);
|
|
await tx.insert(connectionIntentDeliveries).values({ interactionId, companyId: issue.companyId }).onConflictDoNothing();
|
|
}
|
|
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(row));
|
|
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(row));
|
|
return row;
|
|
});
|
|
if (!updated) throw interactionAlreadyResolvedError();
|
|
await touchIssue(db, issue.id);
|
|
const interaction = hydrateInteraction(
|
|
updated,
|
|
) as ConnectionIntentInteraction;
|
|
await emitInteractionResolvedTelemetry(db, interaction);
|
|
return interaction;
|
|
},
|
|
sweepMergedPullRequestConfirmations: async (
|
|
mergedHints: Array<{
|
|
companyId: string;
|
|
owner: string;
|
|
repo: string;
|
|
number: number;
|
|
}> = [],
|
|
) => {
|
|
const rows = await db
|
|
.select({
|
|
interaction: issueThreadInteractions,
|
|
issue: {
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
projectId: issues.projectId,
|
|
goalId: issues.goalId,
|
|
status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
},
|
|
})
|
|
.from(issueThreadInteractions)
|
|
.innerJoin(issues, eq(issueThreadInteractions.issueId, issues.id))
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.kind, "request_confirmation"),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
);
|
|
|
|
const candidates = rows.flatMap((row) => {
|
|
const references = getMergeConfirmationPullRequestReferences(
|
|
row.interaction,
|
|
);
|
|
return references.length > 0 ? [{ ...row, references }] : [];
|
|
});
|
|
if (candidates.length === 0) {
|
|
return { checked: rows.length, candidates: 0, accepted: 0, woken: 0 };
|
|
}
|
|
|
|
const candidateIds = candidates.map(({ interaction }) => interaction.id);
|
|
const linkedToolActions = await db
|
|
.select({ interactionId: toolActionRequests.interactionId })
|
|
.from(toolActionRequests)
|
|
.where(inArray(toolActionRequests.interactionId, candidateIds));
|
|
const toolActionInteractionIds = new Set(
|
|
linkedToolActions
|
|
.map((row) => row.interactionId)
|
|
.filter((value): value is string => Boolean(value)),
|
|
);
|
|
const eligible = candidates.filter(
|
|
({ interaction }) => !toolActionInteractionIds.has(interaction.id),
|
|
);
|
|
|
|
const checkedAt = now().getTime();
|
|
const cacheTtlMs = opts.pullRequestCacheTtlMs ?? 5 * 60 * 1000;
|
|
for (const hint of mergedHints) {
|
|
const key = `${hint.companyId}:${hint.owner.toLowerCase()}/${hint.repo.toLowerCase()}#${hint.number}`;
|
|
setBoundedPullRequestCacheEntry(pullRequestStateCache, key, {
|
|
state: "merged",
|
|
checkedAt,
|
|
});
|
|
}
|
|
const uniqueReferences = new Map<
|
|
string,
|
|
{
|
|
key: string;
|
|
companyId: string;
|
|
reference: GitHubPullRequestReference;
|
|
}
|
|
>();
|
|
for (const candidate of eligible) {
|
|
for (const reference of candidate.references) {
|
|
const key = `${candidate.issue.companyId}:${reference.owner.toLowerCase()}/${reference.repo.toLowerCase()}#${reference.number}`;
|
|
const cached = pullRequestStateCache.get(key);
|
|
if (cached && checkedAt - cached.checkedAt < cacheTtlMs) continue;
|
|
uniqueReferences.set(key, {
|
|
key,
|
|
companyId: candidate.issue.companyId,
|
|
reference,
|
|
});
|
|
}
|
|
}
|
|
|
|
const refreshedStates = await resolvePullRequestStates([
|
|
...uniqueReferences.values(),
|
|
]);
|
|
for (const [key, state] of refreshedStates) {
|
|
setBoundedPullRequestCacheEntry(pullRequestStateCache, key, {
|
|
state,
|
|
checkedAt,
|
|
});
|
|
}
|
|
|
|
let accepted = 0;
|
|
let woken = 0;
|
|
for (const candidate of eligible) {
|
|
const allMerged = candidate.references.every((reference) => {
|
|
const key = `${candidate.issue.companyId}:${reference.owner.toLowerCase()}/${reference.repo.toLowerCase()}#${reference.number}`;
|
|
return pullRequestStateCache.get(key)?.state === "merged";
|
|
});
|
|
if (!allMerged) continue;
|
|
|
|
let resolved: Awaited<ReturnType<typeof acceptRequestConfirmation>>;
|
|
try {
|
|
resolved = await acceptRequestConfirmation({
|
|
issue: candidate.issue,
|
|
current: candidate.interaction,
|
|
input: {},
|
|
actor: {
|
|
systemId: "system:pr-merged",
|
|
resolutionDetails: {
|
|
source: "merged_pull_request_sweep",
|
|
pullRequests: candidate.references.map(
|
|
(reference) =>
|
|
`${reference.owner}/${reference.repo}#${reference.number}`,
|
|
),
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
error &&
|
|
typeof error === "object" &&
|
|
"status" in error &&
|
|
error.status === 409
|
|
)
|
|
continue;
|
|
throw error;
|
|
}
|
|
if (resolved.interaction.status !== "accepted") continue;
|
|
accepted += 1;
|
|
|
|
const wakeIssue = resolved.continuationIssue ?? candidate.issue;
|
|
const shouldWake =
|
|
resolved.interaction.continuationPolicy === "wake_assignee" ||
|
|
resolved.interaction.continuationPolicy === "wake_assignee_on_accept";
|
|
if (
|
|
!opts.wakeup ||
|
|
!shouldWake ||
|
|
!wakeIssue.assigneeAgentId ||
|
|
isTerminalIssueStatus(wakeIssue.status)
|
|
) {
|
|
continue;
|
|
}
|
|
await opts.wakeup(wakeIssue.assigneeAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: {
|
|
issueId: wakeIssue.id,
|
|
interactionId: resolved.interaction.id,
|
|
interactionKind: resolved.interaction.kind,
|
|
interactionStatus: resolved.interaction.status,
|
|
sourceCommentId: resolved.interaction.sourceCommentId ?? null,
|
|
sourceRunId: resolved.interaction.sourceRunId ?? null,
|
|
mutation: "interaction",
|
|
resolutionSource: "merged_pull_request_sweep",
|
|
},
|
|
idempotencyKey: `interaction:${resolved.interaction.id}:accepted`,
|
|
// A merged-PR confirmation may already be visible in an external
|
|
// provider conversation. Keep its continuation causally isolated
|
|
// from unrelated queued chat/internal work on the same task.
|
|
allowRunCoalescing: false,
|
|
requestedByActorType: "system",
|
|
requestedByActorId: "system:pr-merged",
|
|
contextSnapshot: {
|
|
issueId: wakeIssue.id,
|
|
taskId: wakeIssue.id,
|
|
interactionId: resolved.interaction.id,
|
|
interactionKind: resolved.interaction.kind,
|
|
interactionStatus: resolved.interaction.status,
|
|
wakeReason: "issue_commented",
|
|
source: "merged_pull_request_sweep",
|
|
},
|
|
});
|
|
woken += 1;
|
|
}
|
|
|
|
return {
|
|
checked: rows.length,
|
|
candidates: eligible.length,
|
|
accepted,
|
|
woken,
|
|
};
|
|
},
|
|
listForIssue: async (issueId: string) => {
|
|
const [rows, issue] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.issueId, issueId))
|
|
.orderBy(
|
|
asc(issueThreadInteractions.createdAt),
|
|
asc(issueThreadInteractions.id),
|
|
),
|
|
db
|
|
.select({
|
|
status: issues.status,
|
|
companyId: issues.companyId,
|
|
executionWorkspaceId: issues.executionWorkspaceId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((issueRows) => issueRows[0]),
|
|
]);
|
|
|
|
const interactions = rows.map((row) =>
|
|
hydrateInteraction(
|
|
issue &&
|
|
isTerminalIssueStatus(issue.status) &&
|
|
row.status === "pending"
|
|
? {
|
|
...row,
|
|
status: "expired",
|
|
result: buildAdministrativeOutcomeResult(row, "issue_closed"),
|
|
resolvedAt: row.updatedAt,
|
|
}
|
|
: row,
|
|
),
|
|
);
|
|
return withAcceptanceReadiness(interactions, issue);
|
|
},
|
|
|
|
getById: async (interactionId: string) => {
|
|
const row = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
return row ? (await withAcceptanceReadiness([hydrateInteraction(row)]))[0] : null;
|
|
},
|
|
|
|
recordSecretProposalExecutionResult: async (
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
proposalId: string,
|
|
execution: { status: "executed" | "failed"; errorCode?: string | null },
|
|
) => {
|
|
const updated = await db.transaction(async (tx) => {
|
|
// Verdict and terminal-transition paths lock issue -> proposal ->
|
|
// interaction. Take the same order before recording the receipt so a
|
|
// concurrent rejection or issue close cannot deadlock here.
|
|
const lockedIssue = await tx
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(
|
|
and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!lockedIssue) throw notFound("Issue not found");
|
|
|
|
const proposal = await tx
|
|
.select()
|
|
.from(companySecretProposals)
|
|
.where(
|
|
and(
|
|
eq(companySecretProposals.id, proposalId),
|
|
eq(companySecretProposals.companyId, issue.companyId),
|
|
),
|
|
)
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
const current = await tx
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!current ||
|
|
current.companyId !== issue.companyId ||
|
|
current.issueId !== issue.id
|
|
) {
|
|
throw notFound("Interaction not found");
|
|
}
|
|
if (
|
|
!proposal ||
|
|
proposal.interactionId !== interactionId ||
|
|
current.status !== "accepted" ||
|
|
linkedSecretProposalId(current) !== proposalId
|
|
) {
|
|
throw conflict(
|
|
"Secret proposal interaction is not awaiting an execution result",
|
|
);
|
|
}
|
|
const now = new Date();
|
|
const payload =
|
|
current.payload &&
|
|
typeof current.payload === "object" &&
|
|
!Array.isArray(current.payload)
|
|
? (current.payload as unknown as Record<string, unknown>)
|
|
: {};
|
|
const secretProposalPayload =
|
|
payload.secretProposal &&
|
|
typeof payload.secretProposal === "object" &&
|
|
!Array.isArray(payload.secretProposal)
|
|
? (payload.secretProposal as Record<string, unknown>)
|
|
: {};
|
|
const proposalAlreadyExecuted =
|
|
proposal.status === "approved" &&
|
|
proposal.appliedBindingConfigPath ===
|
|
secretProposalPayload.configPath;
|
|
const executionStatus = proposalAlreadyExecuted
|
|
? "executed"
|
|
: execution.status;
|
|
if (executionStatus === "failed" && proposal.status === "pending") {
|
|
const resolutionReason = `Interaction acceptance failed: ${execution.errorCode ?? "secret_proposal_execution_failed"}`;
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.secretProposalExpiry, proposal.companyId);
|
|
await tx
|
|
.update(companySecretProposals)
|
|
.set({
|
|
status: "rejected",
|
|
resolvedByUserId: current.resolvedByUserId ?? null,
|
|
resolvedAt: now,
|
|
resolutionReason,
|
|
valueCiphertext: null,
|
|
ciphertextScrubbedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(companySecretProposals.id, proposal.id),
|
|
eq(companySecretProposals.status, "pending"),
|
|
),
|
|
);
|
|
await logActivity(tx as unknown as Db, {
|
|
companyId: issue.companyId,
|
|
actorType: current.resolvedByUserId ? "user" : "system",
|
|
actorId: current.resolvedByUserId ?? "system",
|
|
action: "secret.proposal.rejected",
|
|
entityType: "company_secret_proposal",
|
|
entityId: proposal.id,
|
|
agentId: proposal.proposedByAgentId,
|
|
runId: proposal.originRunId,
|
|
details: {
|
|
ciphertextScrubbed: true,
|
|
issueId: proposal.originIssueId,
|
|
interactionId: current.id,
|
|
reason: resolutionReason,
|
|
executionFailed: true,
|
|
},
|
|
});
|
|
}
|
|
const result =
|
|
current.result &&
|
|
typeof current.result === "object" &&
|
|
!Array.isArray(current.result)
|
|
? (current.result as unknown as Record<string, unknown>)
|
|
: {};
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
result: {
|
|
...result,
|
|
version: 1,
|
|
outcome: "accepted",
|
|
secretProposal: {
|
|
version: 1,
|
|
status: executionStatus,
|
|
errorCode:
|
|
executionStatus === "failed"
|
|
? (execution.errorCode ?? null)
|
|
: null,
|
|
updatedAt: now.toISOString(),
|
|
},
|
|
},
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(issueThreadInteractions.id, current.id))
|
|
.returning();
|
|
await touchIssue(tx, issue.id);
|
|
return row;
|
|
});
|
|
return hydrateInteraction(updated);
|
|
},
|
|
|
|
cancelPendingForDeletedAddressee: async (
|
|
companyId: string,
|
|
addresseeAgentId: string,
|
|
) => {
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, companyId),
|
|
eq(issueThreadInteractions.addresseeAgentId, addresseeAgentId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
);
|
|
if (rows.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const cancelled: IssueThreadInteraction[] = [];
|
|
for (const row of rows) {
|
|
const updated = await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
|
if (row.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, row.companyId, [row.id]);
|
|
const [resolved] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "cancelled",
|
|
result: buildAdministrativeOutcomeResult(
|
|
row,
|
|
"addressee_deleted",
|
|
"Cancelled because the addressed agent was deleted",
|
|
),
|
|
resolvedByAgentId: null,
|
|
resolvedByRunId: null,
|
|
resolvedByUserId: null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!resolved) return null;
|
|
const cancelledInteraction = hydrateInteraction(resolved);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
cancelledInteraction,
|
|
);
|
|
return cancelledInteraction;
|
|
});
|
|
if (updated) cancelled.push(updated);
|
|
}
|
|
|
|
for (const issueId of new Set(
|
|
cancelled.map((interaction) => interaction.issueId),
|
|
)) {
|
|
await touchIssue(db, issueId);
|
|
}
|
|
await emitResolvedInteractionsTelemetry(db, cancelled);
|
|
return cancelled;
|
|
},
|
|
|
|
sweepSupersededPendingRequestConfirmations: async () => {
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.kind, "request_confirmation"),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
isNotNull(issueThreadInteractions.createdByAgentId),
|
|
),
|
|
)
|
|
.orderBy(
|
|
asc(issueThreadInteractions.companyId),
|
|
asc(issueThreadInteractions.issueId),
|
|
asc(issueThreadInteractions.kind),
|
|
asc(issueThreadInteractions.createdByAgentId),
|
|
desc(issueThreadInteractions.createdAt),
|
|
desc(issueThreadInteractions.id),
|
|
);
|
|
|
|
const newestByGroup = new Map<string, IssueThreadInteractionRow>();
|
|
const supersededRows: Array<{
|
|
row: IssueThreadInteractionRow;
|
|
replacementInteractionId: string;
|
|
}> = [];
|
|
for (const row of rows) {
|
|
if (!row.createdByAgentId) continue;
|
|
const groupKey = `${row.companyId}:${row.issueId}:${row.kind}:${row.createdByAgentId}`;
|
|
const newest = newestByGroup.get(groupKey);
|
|
if (!newest) {
|
|
newestByGroup.set(groupKey, row);
|
|
continue;
|
|
}
|
|
supersededRows.push({ row, replacementInteractionId: newest.id });
|
|
}
|
|
|
|
if (supersededRows.length === 0) return { expired: 0 };
|
|
|
|
const now = new Date();
|
|
const expired: IssueThreadInteraction[] = [];
|
|
for (const { row, replacementInteractionId } of supersededRows) {
|
|
const updated = await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
|
if (row.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, row.companyId, [row.id]);
|
|
const [updatedRow] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildSupersededByNewerRequestResult(
|
|
replacementInteractionId,
|
|
),
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!updatedRow) return null;
|
|
await resolveLinkedToolActionRequests(tx, updatedRow, {
|
|
status: "expired",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor: {},
|
|
now,
|
|
});
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(updatedRow),
|
|
);
|
|
return updatedRow;
|
|
});
|
|
if (!updated) continue;
|
|
expired.push(hydrateInteraction(updated));
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
for (const issueId of new Set(
|
|
expired.map((interaction) => interaction.issueId),
|
|
)) {
|
|
await touchIssue(db, issueId);
|
|
}
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return { expired: expired.length };
|
|
},
|
|
|
|
create: async (
|
|
issue: { id: string; companyId: string },
|
|
input: CreateIssueThreadInteractionInput,
|
|
actor: InteractionActor,
|
|
options: CreateInteractionOptions = {},
|
|
) => {
|
|
const data = normalizeCreateInteractionInput(
|
|
createIssueThreadInteractionSchema.parse(input),
|
|
);
|
|
const qualityItems = data.kind === "request_confirmation" && data.payload.qualityReview
|
|
? [data.payload] : data.kind === "request_item_verdicts"
|
|
? data.payload.items.filter(item => item.qualityReview) : [];
|
|
if (qualityItems.length) {
|
|
if (data.addresseeAgentId) throw unprocessable("Required quality review must be addressed to a human");
|
|
const [owner] = await db.select({ executionPolicy: issues.executionPolicy }).from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)));
|
|
for (const item of qualityItems) item.qualityReview = bindQualityReview(requiredQualityReview(owner?.executionPolicy), item.qualityReview!, item.target, issue.id);
|
|
data.resolverPolicy = "human_only";
|
|
data.continuationPolicy = "wake_assignee";
|
|
if (data.kind === "request_confirmation" || data.kind === "request_item_verdicts") data.payload.supersedeOnUserComment = false;
|
|
}
|
|
// Chat ownership is server-owned and immutable. Ordinary human questions
|
|
// must not depend on a model copying an opaque user identity correctly.
|
|
let defaultAddresseeUserId: string | null = null;
|
|
if (data.kind === "ask_user_questions" && !data.addresseeAgentId) {
|
|
const [conversation] = await db
|
|
.select({ agentId: issues.conversationAgentId, userId: issues.conversationUserId })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)));
|
|
if (conversation?.agentId && conversation.userId) {
|
|
defaultAddresseeUserId = conversation.userId;
|
|
if (data.addresseeUserId && data.addresseeUserId !== defaultAddresseeUserId) {
|
|
throw unprocessable("Chat questions must address the conversation owner; omit addresseeUserId", {
|
|
code: "interaction_chat_addressee_mismatch",
|
|
});
|
|
}
|
|
data.addresseeUserId = defaultAddresseeUserId;
|
|
}
|
|
}
|
|
const usedDeprecatedResolverPolicyAlias =
|
|
data.resolverPolicy === "board_or_agents" ||
|
|
data.resolverPolicy === "board_only";
|
|
const governance = await db
|
|
.select({
|
|
interactionResolverGovernance:
|
|
companies.interactionResolverGovernance,
|
|
})
|
|
.from(companies)
|
|
.where(eq(companies.id, issue.companyId))
|
|
.then((rows) => rows[0]?.interactionResolverGovernance ?? {});
|
|
const policy = resolveInteractionPolicy({
|
|
kind: data.kind,
|
|
requested: data.resolverPolicy,
|
|
governance,
|
|
hasToolAction:
|
|
data.kind === "request_confirmation" &&
|
|
data.payload.toolAction !== undefined,
|
|
hasSecretProposal:
|
|
data.kind === "request_confirmation" &&
|
|
data.payload.secretProposal !== undefined,
|
|
});
|
|
const normalizedData = {
|
|
...data,
|
|
resolverPolicy: policy.requestedResolverPolicy,
|
|
};
|
|
|
|
if (normalizedData.addresseeAgentId && normalizedData.addresseeUserId) {
|
|
throw unprocessable(
|
|
"An issue-thread interaction cannot address both an agent and a user",
|
|
);
|
|
}
|
|
|
|
if (normalizedData.addresseeAgentId) {
|
|
if (normalizedData.addresseeAgentId === actor.agentId) {
|
|
throw unprocessable(
|
|
"Agents cannot address issue-thread interactions to themselves",
|
|
);
|
|
}
|
|
if (
|
|
normalizedData.kind === "request_confirmation" &&
|
|
normalizedData.payload.toolAction !== undefined
|
|
) {
|
|
throw unprocessable(
|
|
"Tool-action confirmations cannot be addressed to agents",
|
|
);
|
|
}
|
|
if (
|
|
normalizedData.kind === "request_confirmation" &&
|
|
normalizedData.payload.secretProposal !== undefined
|
|
) {
|
|
throw unprocessable(
|
|
"Secret-proposal confirmations cannot be addressed to agents",
|
|
);
|
|
}
|
|
const addressee = await db
|
|
.select({
|
|
id: agents.id,
|
|
companyId: agents.companyId,
|
|
name: agents.name,
|
|
reportsTo: agents.reportsTo,
|
|
status: agents.status,
|
|
})
|
|
.from(agents)
|
|
.where(eq(agents.id, normalizedData.addresseeAgentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!addressee || addressee.companyId !== issue.companyId) {
|
|
throw unprocessable(
|
|
"addresseeAgentId must belong to the same company",
|
|
);
|
|
}
|
|
const invokability = await evaluateAgentInvokabilityFromDb(
|
|
db,
|
|
addressee,
|
|
);
|
|
if (!invokability.invokable) {
|
|
throw unprocessable(
|
|
"addresseeAgentId must reference an invokable agent",
|
|
{
|
|
reason: invokability.reason,
|
|
...invokability.details,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (normalizedData.idempotencyKey) {
|
|
const existing = await getIdempotentInteraction({
|
|
issueId: issue.id,
|
|
companyId: issue.companyId,
|
|
idempotencyKey: normalizedData.idempotencyKey,
|
|
});
|
|
if (existing) {
|
|
if (!isEquivalentCreateRequest(existing, normalizedData, actor, defaultAddresseeUserId)) {
|
|
throw conflict(
|
|
"Interaction idempotency key already exists for a different request",
|
|
{
|
|
idempotencyKey: normalizedData.idempotencyKey,
|
|
},
|
|
);
|
|
}
|
|
const interaction = hydrateInteraction(existing);
|
|
await enqueueIssueInteractionChatPublications(db, interaction);
|
|
return interaction;
|
|
}
|
|
}
|
|
|
|
if (data.sourceCommentId) {
|
|
const sourceComment = await db
|
|
.select({
|
|
companyId: issueComments.companyId,
|
|
issueId: issueComments.issueId,
|
|
})
|
|
.from(issueComments)
|
|
.where(eq(issueComments.id, data.sourceCommentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!sourceComment ||
|
|
sourceComment.companyId !== issue.companyId ||
|
|
sourceComment.issueId !== issue.id
|
|
) {
|
|
throw unprocessable(
|
|
"sourceCommentId must belong to the same issue and company",
|
|
);
|
|
}
|
|
}
|
|
|
|
let originCommentIds: string[] = data.sourceCommentId ? [data.sourceCommentId] : [];
|
|
let sourceIdentityContextId: string | null = null;
|
|
let sourceRunContext: Record<string, unknown> | null = null;
|
|
let sourceRunCreatedAt: Date | null = null;
|
|
if (data.sourceRunId) {
|
|
const sourceRun = await db
|
|
.select({
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, data.sourceRunId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!sourceRun || sourceRun.companyId !== issue.companyId) {
|
|
throw unprocessable("sourceRunId must belong to the same company");
|
|
}
|
|
if (data.kind === "ask_user_questions") {
|
|
if (actor.agentId && sourceRun.agentId !== actor.agentId) {
|
|
throw unprocessable("sourceRunId must belong to the creating agent");
|
|
}
|
|
const snapshot = sourceRun.contextSnapshot ?? {};
|
|
const boundIssueIds = [
|
|
sourceRun.nativeIssueId,
|
|
snapshot.issueId,
|
|
snapshot.taskId,
|
|
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
if (boundIssueIds.some((boundIssueId) => boundIssueId !== issue.id)) {
|
|
throw unprocessable("sourceRunId must belong to the same issue");
|
|
}
|
|
}
|
|
sourceRunContext = sourceRun.contextSnapshot;
|
|
sourceRunCreatedAt = sourceRun.createdAt;
|
|
originCommentIds = [...new Set([...originCommentIds, ...await currentContinuationOrigins(db, issue.companyId, issue.id, sourceRun.contextSnapshot)])];
|
|
sourceIdentityContextId = actor.identityContextId ?? sourceRun.activeIdentityContextId;
|
|
if (sourceIdentityContextId) {
|
|
const [origin] = await db.select({id: runIdentityContexts.id}).from(runIdentityContexts).where(and(
|
|
eq(runIdentityContexts.id, sourceIdentityContextId), eq(runIdentityContexts.companyId, issue.companyId),
|
|
eq(runIdentityContexts.runId, data.sourceRunId), eq(runIdentityContexts.status, "accepted"),
|
|
));
|
|
if (!origin) throw unprocessable("Interaction execution identity is unavailable");
|
|
}
|
|
}
|
|
|
|
const requiresCurrentTarget =
|
|
data.kind === "request_confirmation" ||
|
|
data.kind === "request_checkbox_confirmation" ||
|
|
data.kind === "request_item_verdicts";
|
|
|
|
let created: IssueThreadInteractionRow;
|
|
let superseded: IssueThreadInteractionRow[] = [];
|
|
try {
|
|
// A terminal issue must not regain pending actionable cards. FOR UPDATE
|
|
// on the issue row serializes this insert both against terminal status
|
|
// transitions and against concurrent confirmations on the same issue.
|
|
// Idempotent reuse above stays allowed so retries of a pre-close
|
|
// create keep returning the (by now expired) original.
|
|
const result = await db.transaction(async (tx) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
const [issueRow] = await tx
|
|
.select({ executionPolicy: issues.executionPolicy, status: issues.status, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId })
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.id, issue.id),
|
|
eq(issues.companyId, issue.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
// Recheck after taking the shared issue lock: a concurrent request
|
|
// may have registered this retry key while we waited. Key identity is
|
|
// proven by this lookup; request equivalence also checks actor/payload.
|
|
if (normalizedData.idempotencyKey) {
|
|
const existing = await getIdempotentInteraction({ ...issue, issueId: issue.id, idempotencyKey: normalizedData.idempotencyKey }, tx as unknown as Db);
|
|
if (existing) {
|
|
if (!isEquivalentCreateRequest(existing, normalizedData, actor, defaultAddresseeUserId)) throw conflict("Interaction idempotency key already exists for a different request", { idempotencyKey: normalizedData.idempotencyKey });
|
|
await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(existing));
|
|
return { row: existing, supersededRows: [], reused: true };
|
|
}
|
|
}
|
|
if (!issueRow || isTerminalIssueStatus(issueRow.status)) {
|
|
throw conflict("Cannot create an interaction on a closed issue");
|
|
}
|
|
if (data.addresseeUserId) {
|
|
const [user] = await tx.select({ id: authUsers.id }).from(authUsers)
|
|
.where(eq(authUsers.id, data.addresseeUserId));
|
|
const cloudManaged = isCloudManagedInstance();
|
|
// No-login installs have an implicit board, which need not have an
|
|
// auth row. Never infer this authority in authenticated/Cloud mode.
|
|
const localImplicit = data.addresseeUserId === "local-board"
|
|
&& !cloudManaged && resolveDeploymentMode() === "local_trusted";
|
|
// Use the normal board mutation policy, including viewer restrictions,
|
|
// local/instance-admin eligibility and Cloud's no-stale-admin rule.
|
|
const decision = user || localImplicit ? await authorizationService(tx).decide({
|
|
actor: {
|
|
type: "board",
|
|
userId: data.addresseeUserId,
|
|
source: localImplicit ? "local_implicit" : cloudManaged ? "cloud_tenant" : "session",
|
|
},
|
|
action: "issue:mutate",
|
|
resource: { type: "issue", companyId: issue.companyId, issueId: issue.id, ...issueRow },
|
|
}) : null;
|
|
if (!decision?.allowed) {
|
|
throw unprocessable("addresseeUserId must identify a user authorized to respond in this company", {
|
|
code: "interaction_addressee_user_unavailable",
|
|
});
|
|
}
|
|
}
|
|
if (
|
|
data.kind === "ask_user_questions" &&
|
|
sourceRunContext &&
|
|
sourceRunCreatedAt
|
|
) {
|
|
const delivered = deliveredContinuationCommentIds(sourceRunContext);
|
|
if (delivered.known) {
|
|
const newerHumanComments = await tx
|
|
.select({ id: issueComments.id })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, issue.companyId),
|
|
eq(issueComments.issueId, issue.id),
|
|
eq(issueComments.authorType, "user"),
|
|
isNotNull(issueComments.authorUserId),
|
|
ne(issueComments.authorUserId, "board-concierge"),
|
|
isNull(issueComments.createdByRunId),
|
|
eq(issueComments.origin, "comment"),
|
|
isNull(issueComments.deletedAt),
|
|
gte(issueComments.createdAt, sourceRunCreatedAt),
|
|
),
|
|
);
|
|
const undelivered = newerHumanComments.filter(
|
|
(comment) => !delivered.ids.has(comment.id),
|
|
);
|
|
if (undelivered.length > 0) {
|
|
throw conflict(
|
|
"New user comments arrived after this run's context; continue after queued comments are delivered",
|
|
{
|
|
reason: "newer_comment_not_delivered",
|
|
commentIds: undelivered.map((comment) => comment.id),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// Validate the plan/document confirmation target inside the same
|
|
// transaction (locking the document row) so the latest-revision check
|
|
// is atomic with the insert below. A concurrent revision publish can no
|
|
// longer slip between the check and the insert to leave a confirmation
|
|
// pointing at a stale revision.
|
|
if (requiresCurrentTarget) {
|
|
await assertRequestConfirmationTargetIsCurrent(tx, {
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
target: data.payload.target ?? null,
|
|
lockForUpdate: true,
|
|
});
|
|
}
|
|
for (const item of qualityItems) {
|
|
const binding = bindQualityReview(requiredQualityReview(issueRow.executionPolicy), item.qualityReview!, item.target, issue.id);
|
|
if (binding.requirementHash !== item.qualityReview!.requirementHash) throw conflict("Quality review requirements changed; read the task again");
|
|
await assertRequestConfirmationTargetIsCurrent(tx, { companyId: issue.companyId, issueId: issue.id, target: item.target, lockForUpdate: true });
|
|
}
|
|
if (qualityItems.length) {
|
|
const keys = qualityItems.map(item => item.qualityReview!.key);
|
|
// The issue lock serializes creates across agents and reviewers.
|
|
// Ten is also the schema limit on required deliverables: bounding
|
|
// task-wide cards bounds every reviewer's queue, even after a human
|
|
// changes the requirement while older cards are still pending.
|
|
const pendingQuality = await tx.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
inArray(issueThreadInteractions.kind, ["request_confirmation", "request_item_verdicts"]),
|
|
sql`(jsonb_path_exists(${issueThreadInteractions.payload}, '$.qualityReview ? (@ != null)') OR
|
|
jsonb_path_exists(${issueThreadInteractions.payload}, '$.items[*].qualityReview ? (@ != null)'))`,
|
|
)).limit(11);
|
|
for (const pending of pendingQuality) {
|
|
const interaction = hydrateInteraction(pending);
|
|
const existingKeys = interaction.kind === "request_confirmation" ? [interaction.payload.qualityReview?.key]
|
|
: interaction.kind === "request_item_verdicts" ? interaction.payload.items.filter(item => !interaction.result?.items.some(result => result.id === item.id)).map(item => item.qualityReview?.key) : [];
|
|
if (!existingKeys.some(key => key && keys.includes(key))) continue;
|
|
if (isEquivalentCreateRequest(pending, { ...normalizedData, idempotencyKey: pending.idempotencyKey ?? undefined }, actor, defaultAddresseeUserId)) {
|
|
if (normalizedData.idempotencyKey) {
|
|
const aliases = await tx.select({ id: issueThreadInteractionIdempotencyKeys.id }).from(issueThreadInteractionIdempotencyKeys)
|
|
.where(eq(issueThreadInteractionIdempotencyKeys.interactionId, pending.id)).limit(100);
|
|
if (aliases.length >= 100) throw conflict("This review has reached its retry-key limit; use its existing idempotency key", { code: "quality_review_retry_key_limit", interactionId: pending.id });
|
|
await tx.insert(issueThreadInteractionIdempotencyKeys).values({ companyId: issue.companyId, issueId: issue.id,
|
|
interactionId: pending.id, idempotencyKey: normalizedData.idempotencyKey });
|
|
}
|
|
await enqueueIssueInteractionChatPublications(tx as unknown as Db, interaction);
|
|
return { row: pending, supersededRows: [], reused: true };
|
|
}
|
|
throw conflict("A quality review for this output is already pending; resolve or cancel it before requesting another", {
|
|
code: "quality_review_already_pending", interactionId: pending.id,
|
|
});
|
|
}
|
|
if (pendingQuality.length >= 10) throw conflict("This task already has ten pending quality reviews; resolve or cancel an existing review first", {
|
|
code: "quality_review_pending_limit",
|
|
});
|
|
}
|
|
// An unverified phone caller has no authenticated human identity.
|
|
// Their clarifications use the ordinary task-comment/follow-up queue;
|
|
// a protected native question would otherwise wait indefinitely.
|
|
if (data.kind === "ask_user_questions" && !actor.userId) {
|
|
const [guest] = await tx.select({id: chatVoiceSessions.id}).from(chatVoiceSessions)
|
|
.where(and(eq(chatVoiceSessions.companyId, issue.companyId), eq(chatVoiceSessions.issueId, issue.id),
|
|
eq(chatVoiceSessions.callerAuthority, "guest_intake"))).limit(1);
|
|
if (guest) throw unprocessable("Ask this unverified caller a clarification in a task comment; submit_request will deliver their spoken follow-up. Protected human-input questions require authenticated access.", {code: "voice_guest_use_task_comment"});
|
|
}
|
|
if (data.kind === "ask_user_questions" || data.kind === "request_confirmation") {
|
|
await notifyChatRunMilestones(tx, { companyId: issue.companyId, issueId: issue.id });
|
|
}
|
|
const [row] = await tx
|
|
.insert(issueThreadInteractions)
|
|
.values({
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
kind: data.kind,
|
|
status: "pending",
|
|
continuationPolicy: data.continuationPolicy,
|
|
requestedResolverPolicy: policy.requestedResolverPolicy,
|
|
effectiveResolverPolicy: policy.effectiveResolverPolicy,
|
|
resolverPolicyProvenance: policy.resolverPolicyProvenance,
|
|
effectiveResolverPolicySource:
|
|
policy.effectiveResolverPolicySource,
|
|
idempotencyKey: data.idempotencyKey ?? null,
|
|
originCommentIds,
|
|
sourceCommentId: data.sourceCommentId ?? null,
|
|
sourceRunId: data.sourceRunId ?? null,
|
|
sourceIdentityContextId,
|
|
title: data.title ?? null,
|
|
summary: data.summary ?? null,
|
|
createdByAgentId: actor.agentId ?? null,
|
|
addresseeAgentId: data.addresseeAgentId ?? null,
|
|
addresseeUserId: data.addresseeUserId ?? null,
|
|
createdByUserId: actor.userId ?? null,
|
|
payload: data.payload,
|
|
})
|
|
.returning();
|
|
|
|
if (row.addresseeAgentId || row.addresseeUserId) {
|
|
const [privacyIssue] = await tx.select().from(issues).where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)));
|
|
await ensureAssignmentIssueAccessGrant(tx, { ...privacyIssue!,
|
|
assigneeAgentId: row.addresseeAgentId, assigneeUserId: row.addresseeUserId,
|
|
}, null, { agentId: actor.agentId, userId: actor.userId });
|
|
}
|
|
|
|
// An agent replacing its own still-pending card supersedes the older
|
|
// one so the thread never accumulates stale sibling cards. This covers
|
|
// request_confirmation drafts and ordinary task questions. Agent Chat
|
|
// questions remain answerable in history even when another is asked.
|
|
// Scoped to the same agent + issue + kind; other actors are untouched.
|
|
const canSupersedeSiblingCards =
|
|
options.supersedePendingSiblingInteractions !== false &&
|
|
((data.kind === "request_confirmation" &&
|
|
data.payload.toolAction === undefined &&
|
|
data.payload.secretProposal === undefined &&
|
|
data.payload.qualityReview === undefined) ||
|
|
(data.kind === "ask_user_questions" && (!issueRow.conversationAgentId || !issueRow.conversationUserId)));
|
|
if (!actor.agentId || !canSupersedeSiblingCards) {
|
|
await enqueueIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(row),
|
|
);
|
|
return { row, supersededRows: [] };
|
|
}
|
|
|
|
const now = new Date();
|
|
const supersededResult =
|
|
data.kind === "ask_user_questions"
|
|
? buildSupersededByNewerInteractionResult(row.id)
|
|
: buildSupersededByNewerRequestResult(row.id);
|
|
const siblingCondition = and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.kind, data.kind),
|
|
eq(issueThreadInteractions.createdByAgentId, actor.agentId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
ne(issueThreadInteractions.id, row.id),
|
|
sql`${issueThreadInteractions.payload}->'qualityReview' IS NULL`,
|
|
);
|
|
if (data.kind === "request_confirmation") {
|
|
// The issue lock above excludes concurrent interaction creation.
|
|
const siblings = await tx.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(siblingCondition);
|
|
await notifyToolProjectionForInteractions(tx, issue.companyId, siblings.map(sibling => sibling.id));
|
|
}
|
|
const supersededRows = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: supersededResult,
|
|
resolvedByAgentId: actor.agentId,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(siblingCondition)
|
|
.returning();
|
|
for (const supersededRow of supersededRows) {
|
|
await resolveLinkedToolActionRequests(tx, supersededRow, {
|
|
status: "expired",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor,
|
|
now,
|
|
});
|
|
}
|
|
await cancelPendingIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
{
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
interactionIds: supersededRows.map((candidate) => candidate.id),
|
|
},
|
|
);
|
|
for (const supersededRow of supersededRows) {
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(supersededRow),
|
|
);
|
|
}
|
|
await enqueueIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(row),
|
|
);
|
|
return { row, supersededRows };
|
|
});
|
|
if ("reused" in result && result.reused) return hydrateInteraction(result.row);
|
|
created = result.row;
|
|
superseded = result.supersededRows;
|
|
} catch (error) {
|
|
if (
|
|
!normalizedData.idempotencyKey ||
|
|
!isUniqueViolation(error, ISSUE_THREAD_INTERACTION_IDEMPOTENCY_CONSTRAINT)
|
|
) {
|
|
throw error;
|
|
}
|
|
const existing = await getIdempotentInteraction({
|
|
issueId: issue.id,
|
|
companyId: issue.companyId,
|
|
idempotencyKey: normalizedData.idempotencyKey,
|
|
});
|
|
if (!existing) throw error;
|
|
if (!isEquivalentCreateRequest(existing, normalizedData, actor, defaultAddresseeUserId)) {
|
|
throw conflict(
|
|
"Interaction idempotency key already exists for a different request",
|
|
{
|
|
idempotencyKey: normalizedData.idempotencyKey,
|
|
},
|
|
);
|
|
}
|
|
const interaction = hydrateInteraction(existing);
|
|
await enqueueIssueInteractionChatPublications(db, interaction);
|
|
return interaction;
|
|
}
|
|
|
|
await touchIssue(db, issue.id);
|
|
if (superseded.length > 0) {
|
|
await emitResolvedInteractionsTelemetry(
|
|
db,
|
|
superseded.map(hydrateInteraction),
|
|
);
|
|
}
|
|
const interaction = hydrateInteraction(created);
|
|
emitInteractionCreatedTelemetry({
|
|
interactionKind: interaction.kind,
|
|
usedDeprecatedResolverPolicyAlias,
|
|
});
|
|
return interaction;
|
|
},
|
|
|
|
acceptInteraction: async (
|
|
issue: {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
goalId: string | null;
|
|
status?: string;
|
|
},
|
|
interactionId: string,
|
|
input: AcceptIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
): Promise<ResolvedInteractionResult> => {
|
|
const data = acceptIssueThreadInteractionSchema.parse(input);
|
|
const current = await getPendingInteractionForResolution({
|
|
issue,
|
|
interactionId,
|
|
});
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
switch (current.kind) {
|
|
case "suggest_tasks":
|
|
// Accepting suggest_tasks only creates follow-up issues; it does not
|
|
// approve code state or move the source workspace forward, so the
|
|
// workspace_finalize gate (PAPA-440) does not apply here.
|
|
return issueThreadInteractionService(db).acceptSuggestedTasks(
|
|
issue,
|
|
interactionId,
|
|
data,
|
|
actor,
|
|
mutationOptions,
|
|
);
|
|
case "request_confirmation": {
|
|
await assertIssueWorkspaceFinalizedForAccept({
|
|
db,
|
|
issue,
|
|
sourceRunId: current.sourceRunId,
|
|
});
|
|
const accepted = await acceptRequestConfirmation({
|
|
issue,
|
|
current,
|
|
input: data,
|
|
actor,
|
|
mutationOptions,
|
|
});
|
|
return {
|
|
interaction: accepted.interaction,
|
|
continuationIssue: accepted.continuationIssue,
|
|
createdIssues: [],
|
|
};
|
|
}
|
|
case "request_checkbox_confirmation": {
|
|
await assertIssueWorkspaceFinalizedForAccept({
|
|
db,
|
|
issue,
|
|
sourceRunId: current.sourceRunId,
|
|
});
|
|
const accepted = await acceptRequestConfirmation({
|
|
issue,
|
|
current,
|
|
input: data,
|
|
actor,
|
|
mutationOptions,
|
|
});
|
|
return {
|
|
interaction: accepted.interaction,
|
|
continuationIssue: accepted.continuationIssue,
|
|
createdIssues: [],
|
|
};
|
|
}
|
|
default:
|
|
throw unprocessable(
|
|
`Interactions of kind ${current.kind} cannot be accepted`,
|
|
);
|
|
}
|
|
},
|
|
|
|
acceptSuggestedTasks: async (
|
|
issue: {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
goalId: string | null;
|
|
status?: string;
|
|
},
|
|
interactionId: string,
|
|
input: AcceptIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!current) throw interactionNotFoundError();
|
|
if (
|
|
current.companyId !== issue.companyId ||
|
|
current.issueId !== issue.id
|
|
) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
if (current.kind !== "suggest_tasks") {
|
|
throw unprocessable("Only suggest_tasks interactions can be accepted");
|
|
}
|
|
if (current.status !== "pending") {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
if (actor.agentId && actor.suggestedTaskEffectsAuthorized !== true) {
|
|
throw issueThreadInteractionResolutionError(
|
|
403,
|
|
"interaction_governed_action_denied",
|
|
"Suggested-task creation requires independent task-creation authorization",
|
|
);
|
|
}
|
|
|
|
const interaction = hydrateInteraction(
|
|
current,
|
|
) as SuggestTasksInteraction;
|
|
const { selectedTasks, skippedClientKeys } =
|
|
resolveSelectedSuggestedTasks({
|
|
interaction,
|
|
selectedClientKeys: input.selectedClientKeys,
|
|
});
|
|
const orderedTasks = buildTaskCreationOrder(selectedTasks);
|
|
const explicitParentIds = [
|
|
...new Set([
|
|
issue.id,
|
|
...(interaction.payload.defaultParentId
|
|
? [interaction.payload.defaultParentId]
|
|
: []),
|
|
...selectedTasks
|
|
.map((task) => task.parentId ?? null)
|
|
.filter((value): value is string => Boolean(value)),
|
|
]),
|
|
];
|
|
|
|
const parentRows =
|
|
explicitParentIds.length === 0
|
|
? []
|
|
: await db
|
|
.select({
|
|
id: issues.id,
|
|
identifier: issues.identifier,
|
|
companyId: issues.companyId,
|
|
conversationAgentId: issues.conversationAgentId,
|
|
conversationSessionGeneration: issues.conversationSessionGeneration,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, issue.companyId),
|
|
inArray(issues.id, explicitParentIds),
|
|
),
|
|
);
|
|
if (parentRows.length !== explicitParentIds.length) {
|
|
throw unprocessable(
|
|
"Suggested tasks reference parent issues outside this company or issue tree",
|
|
);
|
|
}
|
|
|
|
const parentById = new Map(
|
|
parentRows.map((row) => [row.id, row] as const),
|
|
);
|
|
const sourceConversation = parentById.get(issue.id)?.conversationAgentId
|
|
? parentById.get(issue.id)!
|
|
: null;
|
|
const createdByClientKey = new Map<
|
|
string,
|
|
SuggestTasksResultCreatedTask
|
|
>();
|
|
const createdWakeTargets: IssueWakeTarget[] = [];
|
|
|
|
await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, current);
|
|
await mutationOptions.beforeResolveInTransaction?.(tx);
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
if (sourceConversation) {
|
|
await lockAcceptedChatHandoffSource(tx, {
|
|
companyId: issue.companyId, conversationId: sourceConversation.id,
|
|
sessionGeneration: sourceConversation.conversationSessionGeneration,
|
|
userId: actor.userId, agentId: actor.agentId,
|
|
});
|
|
if (interaction.sourceRunId) {
|
|
const [sourceRun] = await tx.select({ context: heartbeatRuns.contextSnapshot }).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, interaction.sourceRunId), eq(heartbeatRuns.companyId, issue.companyId)));
|
|
if (sourceRun?.context?.conversationSessionGeneration !== sourceConversation.conversationSessionGeneration) {
|
|
throw conflict("The originating conversation was reset; request a new task suggestion");
|
|
}
|
|
}
|
|
}
|
|
const resolvedAt = new Date();
|
|
const [claimed] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt,
|
|
updatedAt: resolvedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
if (!claimed) {
|
|
throw interactionAlreadyResolvedError();
|
|
}
|
|
|
|
for (const task of orderedTasks) {
|
|
const proposedParentId = task.parentClientKey
|
|
? (createdByClientKey.get(task.parentClientKey)?.issueId ?? null)
|
|
: (task.parentId ??
|
|
interaction.payload.defaultParentId ??
|
|
(sourceConversation ? null : issue.id));
|
|
const parentIssueId = proposedParentId === sourceConversation?.id ? null : proposedParentId;
|
|
if (!parentIssueId && task.parentClientKey) {
|
|
throw unprocessable(
|
|
`Unable to resolve parent for suggested task ${task.clientKey}`,
|
|
);
|
|
}
|
|
|
|
const createInput = {
|
|
title: task.title,
|
|
description: task.description ?? null,
|
|
initialPlan: task.initialPlan ?? null,
|
|
...(task.requiredQualityReview ? { executionPolicy: { mode: "normal" as const, commentRequired: true, stages: [], requiredQualityReview: task.requiredQualityReview } } : {}),
|
|
status: "todo",
|
|
workMode: task.workMode ?? "standard",
|
|
priority: task.priority ?? "medium",
|
|
...resolveChatTaskAssignment(task, sourceConversation?.conversationAgentId),
|
|
projectId: task.projectId ?? issue.projectId,
|
|
goalId: task.goalId ?? issue.goalId,
|
|
billingCode: task.billingCode ?? null,
|
|
createdByAgentId: actor.agentId ?? null,
|
|
createdByUserId: actor.userId ?? null,
|
|
originIdentityContextId: interaction.sourceIdentityContextId ?? null,
|
|
originRunId: interaction.sourceRunId ?? null,
|
|
actorAgentId: actor.agentId ?? null,
|
|
actorUserId: actor.userId ?? null,
|
|
...(sourceConversation ? { conversationHandoff: {
|
|
conversationId: sourceConversation.id,
|
|
sessionGeneration: sourceConversation.conversationSessionGeneration,
|
|
} } : {}),
|
|
} satisfies Parameters<ReturnType<typeof issueService>["createChild"]>[1];
|
|
const service = issueService(tx as unknown as Db);
|
|
const createdIssue = parentIssueId
|
|
? (await service.createChild(parentIssueId, createInput)).issue
|
|
: await service.create(issue.companyId, createInput);
|
|
|
|
const parentIdentifier =
|
|
createdByClientKey.get(task.parentClientKey ?? "")?.identifier ??
|
|
(parentIssueId ? parentById.get(parentIssueId)?.identifier : null) ??
|
|
null;
|
|
createdByClientKey.set(task.clientKey, {
|
|
clientKey: task.clientKey,
|
|
issueId: createdIssue.id,
|
|
identifier: createdIssue.identifier ?? null,
|
|
title: createdIssue.title,
|
|
parentIssueId,
|
|
parentIdentifier,
|
|
});
|
|
createdWakeTargets.push({
|
|
id: createdIssue.id,
|
|
assigneeAgentId: createdIssue.assigneeAgentId ?? null,
|
|
status: createdIssue.status,
|
|
});
|
|
}
|
|
|
|
const [updated] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
result: {
|
|
version: 1,
|
|
createdTasks: [...createdByClientKey.values()],
|
|
...(skippedClientKeys.length > 0 ? { skippedClientKeys } : {}),
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.returning();
|
|
|
|
await touchIssue(tx, issue.id);
|
|
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(updated));
|
|
await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(updated));
|
|
current.status = updated.status;
|
|
current.result = updated.result;
|
|
current.resolvedByAgentId = updated.resolvedByAgentId;
|
|
current.resolvedByUserId = updated.resolvedByUserId;
|
|
current.resolvedAt = updated.resolvedAt;
|
|
current.updatedAt = updated.updatedAt;
|
|
});
|
|
|
|
const accepted = hydrateInteraction(current);
|
|
await emitInteractionResolvedTelemetry(db, accepted, {
|
|
createdTaskCount: createdWakeTargets.length,
|
|
});
|
|
return {
|
|
interaction: accepted,
|
|
createdIssues: createdWakeTargets,
|
|
};
|
|
},
|
|
|
|
rejectInteraction: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: RejectIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
const data = rejectIssueThreadInteractionSchema.parse(input);
|
|
const current = await getPendingInteractionForResolution({
|
|
issue,
|
|
interactionId,
|
|
});
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
switch (current.kind) {
|
|
case "suggest_tasks":
|
|
return issueThreadInteractionService(db).rejectSuggestedTasks(
|
|
issue,
|
|
interactionId,
|
|
data,
|
|
actor,
|
|
current,
|
|
mutationOptions,
|
|
);
|
|
case "request_confirmation":
|
|
case "request_checkbox_confirmation":
|
|
return rejectRequestConfirmation({
|
|
issue,
|
|
current,
|
|
input: data,
|
|
actor,
|
|
mutationOptions,
|
|
});
|
|
default:
|
|
throw unprocessable(
|
|
`Interactions of kind ${current.kind} cannot be rejected`,
|
|
);
|
|
}
|
|
},
|
|
|
|
submitItemVerdicts: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: SubmitIssueThreadInteractionVerdicts,
|
|
actor: InteractionActor,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
): Promise<{
|
|
interaction: IssueThreadInteraction;
|
|
newlyResolvedItemIds: string[];
|
|
}> => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const data = submitIssueThreadInteractionVerdictsSchema.parse(input);
|
|
const submission = await db.transaction(async (tx) => {
|
|
await mutationOptions.beforeResolveInTransaction?.(tx);
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
// Match completion/confirmation ordering: issue -> interaction -> output.
|
|
// A pending multi-item review can contain already-approved output slots,
|
|
// so a replay must not lock those outputs ahead of task completion.
|
|
const lockedIssue = await tx.select({ id: issues.id, companyId: issues.companyId, status: issues.status })
|
|
.from(issues).where(eq(issues.id, issue.id)).for("update").then(rows => rows[0] ?? null);
|
|
if (!lockedIssue || lockedIssue.companyId !== issue.companyId) throw notFound("Issue not found");
|
|
assertIssueOpenForInteractionResolution(lockedIssue);
|
|
const current = await tx
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!current) throw interactionNotFoundError();
|
|
if (
|
|
current.companyId !== issue.companyId ||
|
|
current.issueId !== issue.id
|
|
) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (current.kind !== "request_item_verdicts") {
|
|
throw unprocessable(
|
|
"Only request_item_verdicts interactions can receive item verdicts",
|
|
);
|
|
}
|
|
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
const interaction = hydrateInteraction(
|
|
current,
|
|
) as RequestItemVerdictsInteraction;
|
|
if (current.status !== "pending") {
|
|
if (current.status === "answered") {
|
|
const resolvedIds = new Set(
|
|
interaction.result?.items.map((item) => item.id) ?? [],
|
|
);
|
|
const payloadIds = new Set(
|
|
interaction.payload.items.map((item) => item.id),
|
|
);
|
|
for (const submitted of data.verdicts) {
|
|
if (!payloadIds.has(submitted.id)) {
|
|
throw unprocessable(`Unknown item verdict id: ${submitted.id}`);
|
|
}
|
|
if (!resolvedIds.has(submitted.id)) {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
}
|
|
return { interaction, newlyResolvedItemIds: [], resolved: false };
|
|
}
|
|
throw interactionTerminalError(current);
|
|
}
|
|
const expired = await expireStaleRequestConfirmationTarget(tx, {
|
|
row: current,
|
|
actor,
|
|
});
|
|
if (expired) {
|
|
return {
|
|
interaction: expired,
|
|
newlyResolvedItemIds: [],
|
|
resolved: false,
|
|
terminalError: interactionTerminalError({
|
|
status: expired.status,
|
|
result: expired.result,
|
|
}),
|
|
};
|
|
}
|
|
|
|
await assertRequestConfirmationTargetIsCurrent(tx, {
|
|
companyId: issue.companyId, issueId: issue.id,
|
|
target: interaction.payload.target, lockForUpdate: true,
|
|
});
|
|
for (const item of interaction.payload.items.filter(item => data.verdicts.some(verdict => verdict.id === item.id))) {
|
|
await assertRequestConfirmationTargetIsCurrent(tx, { companyId: issue.companyId, issueId: issue.id, target: item.target, lockForUpdate: true });
|
|
}
|
|
const now = new Date();
|
|
const { items, complete, newlyResolvedItemIds } =
|
|
resolveRequestItemVerdictSubmissions({
|
|
interaction,
|
|
input: data,
|
|
actor,
|
|
now,
|
|
});
|
|
if (newlyResolvedItemIds.length === 0) {
|
|
return { interaction, newlyResolvedItemIds: [], resolved: false };
|
|
}
|
|
|
|
const result = {
|
|
version: 1,
|
|
outcome: "resolved",
|
|
complete,
|
|
items,
|
|
} satisfies RequestItemVerdictsResult;
|
|
const [updated] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: complete ? "answered" : "pending",
|
|
result,
|
|
resolvedByAgentId: complete ? (actor.agentId ?? null) : null,
|
|
resolvedByRunId: complete ? (actor.runId ?? null) : null,
|
|
resolvedByUserId: complete ? (actor.userId ?? null) : null,
|
|
resolvedAt: complete ? now : null,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
throw interactionAlreadyResolvedError();
|
|
}
|
|
|
|
await touchIssue(tx, issue.id);
|
|
await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(updated));
|
|
if (complete) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(updated));
|
|
return {
|
|
interaction: hydrateInteraction(updated),
|
|
newlyResolvedItemIds,
|
|
resolved: complete,
|
|
};
|
|
});
|
|
|
|
if ("terminalError" in submission) throw submission.terminalError;
|
|
if (submission.resolved) {
|
|
await emitInteractionResolvedTelemetry(db, submission.interaction);
|
|
}
|
|
return submission;
|
|
},
|
|
|
|
rejectSuggestedTasks: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: RejectIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
current: IssueThreadInteractionRow,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
if (
|
|
current.companyId !== issue.companyId ||
|
|
current.issueId !== issue.id
|
|
) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (current.kind !== "suggest_tasks") {
|
|
throw unprocessable("Only suggest_tasks interactions can be rejected");
|
|
}
|
|
if (current.status !== "pending") {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
|
|
const [updated] = await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, current);
|
|
await mutationOptions.beforeResolveInTransaction?.(tx);
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
const rows = await tx.update(issueThreadInteractions)
|
|
.set({
|
|
status: "rejected",
|
|
result: {
|
|
version: 1,
|
|
rejectionReason: input.reason?.trim() || null,
|
|
},
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (rows[0]) await mutationOptions.afterResolveInTransaction?.(tx, hydrateInteraction(rows[0]));
|
|
if (rows[0]) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(rows[0]));
|
|
return rows;
|
|
});
|
|
|
|
if (!updated) {
|
|
throw interactionAlreadyResolvedError();
|
|
}
|
|
|
|
await touchIssue(db, issue.id);
|
|
const rejected = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, rejected);
|
|
return rejected;
|
|
},
|
|
|
|
expireRequestConfirmationsSupersededByComment: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
comment: {
|
|
id: string;
|
|
createdAt: Date | string;
|
|
authorUserId?: string | null;
|
|
createdByRunId?: string | null;
|
|
origin?: string | null;
|
|
},
|
|
actor: InteractionActor,
|
|
) => {
|
|
if (!comment.authorUserId || comment.origin === "fast_response") return [];
|
|
// Local-CLI adapters post under user auth, so authorUserId can't tell a human from a
|
|
// machine; createdByRunId can. Only genuine human comments (no run context) supersede.
|
|
if (comment.createdByRunId) return [];
|
|
|
|
const [scope] = await db.select({ conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId })
|
|
.from(issues).where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId)));
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
inArray(issueThreadInteractions.kind, [
|
|
...USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS,
|
|
]),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
);
|
|
|
|
const superseded = rows.filter((row) => {
|
|
if (row.kind === "ask_user_questions" && scope?.conversationAgentId && scope.conversationUserId) return false;
|
|
if (!isUserCommentSupersedableKind(row.kind)) return false;
|
|
const interaction = hydrateInteraction(
|
|
row,
|
|
) as UserCommentSupersedableInteraction;
|
|
return (
|
|
shouldSupersedeInteractionOnUserComment(interaction) &&
|
|
isCommentAtOrAfterInteraction({
|
|
commentCreatedAt: comment.createdAt,
|
|
interactionCreatedAt: row.createdAt,
|
|
})
|
|
);
|
|
});
|
|
|
|
if (superseded.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const expired: IssueThreadInteraction[] = [];
|
|
for (const row of superseded) {
|
|
const updated = await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
|
if (row.kind === "connection_intent") {
|
|
await tx
|
|
.delete(toolOauthStates)
|
|
.where(eq(toolOauthStates.interactionId, row.id));
|
|
}
|
|
if (row.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, row.companyId, [row.id]);
|
|
const [resolved] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildSupersededByCommentResult(row, comment.id),
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!resolved) return null;
|
|
const interaction = hydrateInteraction(resolved);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
interaction,
|
|
);
|
|
return interaction;
|
|
});
|
|
if (updated) expired.push(updated);
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
await touchIssue(db, issue.id);
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return expired;
|
|
},
|
|
|
|
expireRequestConfirmationsSupersededByHistoricalComments: async (issue: {
|
|
id: string;
|
|
companyId: string;
|
|
}) => {
|
|
const [rows, comments] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
inArray(issueThreadInteractions.kind, [
|
|
...USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS,
|
|
]),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
),
|
|
db
|
|
.select()
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, issue.companyId),
|
|
eq(issueComments.issueId, issue.id),
|
|
isNotNull(issueComments.authorUserId),
|
|
// Only genuine human comments supersede; machine-originated ones carry createdByRunId.
|
|
isNull(issueComments.createdByRunId),
|
|
eq(issueComments.origin, "comment"),
|
|
),
|
|
)
|
|
.orderBy(asc(issueComments.createdAt)),
|
|
]);
|
|
|
|
if (rows.length === 0 || comments.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const expired: IssueThreadInteraction[] = [];
|
|
const supersededByComment = new Map<
|
|
string,
|
|
{
|
|
comment: (typeof comments)[number];
|
|
rowIds: string[];
|
|
}
|
|
>();
|
|
for (const row of rows) {
|
|
if (!isUserCommentSupersedableKind(row.kind)) continue;
|
|
const interaction = hydrateInteraction(
|
|
row,
|
|
) as UserCommentSupersedableInteraction;
|
|
if (!shouldSupersedeInteractionOnUserComment(interaction)) continue;
|
|
|
|
const supersedingComment = comments.find((comment) =>
|
|
isCommentAtOrAfterInteraction({
|
|
commentCreatedAt: comment.createdAt,
|
|
interactionCreatedAt: row.createdAt,
|
|
}),
|
|
);
|
|
if (!supersedingComment) continue;
|
|
|
|
const group = supersededByComment.get(supersedingComment.id);
|
|
if (group) {
|
|
group.rowIds.push(row.id);
|
|
} else {
|
|
supersededByComment.set(supersedingComment.id, {
|
|
comment: supersedingComment,
|
|
rowIds: [row.id],
|
|
});
|
|
}
|
|
}
|
|
|
|
const rowById = new Map(rows.map((row) => [row.id, row] as const));
|
|
for (const { comment, rowIds } of supersededByComment.values()) {
|
|
const commentRows = rowIds
|
|
.map((rowId) => rowById.get(rowId))
|
|
.filter((row): row is IssueThreadInteractionRow => Boolean(row));
|
|
const questionRowIds = commentRows
|
|
.filter((row) => row.kind === "ask_user_questions")
|
|
.map((row) => row.id);
|
|
const confirmationRowIds = commentRows
|
|
.filter((row) => isRequestConfirmationLikeKind(row.kind))
|
|
.map((row) => row.id);
|
|
const itemVerdictRows = commentRows.filter(
|
|
(row) => row.kind === "request_item_verdicts",
|
|
);
|
|
const connectionIntentRows = commentRows.filter(
|
|
(row) => row.kind === "connection_intent",
|
|
);
|
|
|
|
if (questionRowIds.length > 0) {
|
|
const sampleQuestionRow = commentRows.find(
|
|
(row) => row.kind === "ask_user_questions",
|
|
);
|
|
if (!sampleQuestionRow) continue;
|
|
const updatedInteractions = await db.transaction(async (tx) => {
|
|
await notifyChatRunMilestones(tx, sampleQuestionRow);
|
|
const updatedRows = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildSupersededByCommentResult(
|
|
sampleQuestionRow,
|
|
comment.id,
|
|
),
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: comment.authorUserId,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
inArray(issueThreadInteractions.id, questionRowIds),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
const interactions = updatedRows.map(hydrateInteraction);
|
|
for (const interaction of interactions) {
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
interaction,
|
|
);
|
|
}
|
|
return interactions;
|
|
});
|
|
expired.push(...updatedInteractions);
|
|
}
|
|
|
|
if (confirmationRowIds.length > 0) {
|
|
const sampleConfirmationRow = commentRows.find((row) =>
|
|
isRequestConfirmationLikeKind(row.kind),
|
|
);
|
|
if (!sampleConfirmationRow) continue;
|
|
const updatedInteractions = await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, sampleConfirmationRow);
|
|
if (commentRows.some(row => row.kind === "request_confirmation")) await notifyChatRunMilestones(tx, sampleConfirmationRow);
|
|
await notifyToolProjectionForInteractions(tx, issue.companyId, confirmationRowIds);
|
|
const updatedRows = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildSupersededByCommentResult(
|
|
sampleConfirmationRow,
|
|
comment.id,
|
|
),
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: comment.authorUserId,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
inArray(issueThreadInteractions.id, confirmationRowIds),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
const interactions = updatedRows.map(hydrateInteraction);
|
|
for (const interaction of interactions) {
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
interaction,
|
|
);
|
|
}
|
|
return interactions;
|
|
});
|
|
expired.push(...updatedInteractions);
|
|
}
|
|
|
|
for (const row of itemVerdictRows) {
|
|
const updated = await db.transaction(async tx => {
|
|
const [retired] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildSupersededByCommentResult(row, comment.id),
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: comment.authorUserId,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (retired) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(retired));
|
|
return retired;
|
|
});
|
|
if (updated) expired.push(hydrateInteraction(updated));
|
|
}
|
|
|
|
for (const row of connectionIntentRows) {
|
|
const [updated] = await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
await tx
|
|
.delete(toolOauthStates)
|
|
.where(eq(toolOauthStates.interactionId, row.id));
|
|
return tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildSupersededByCommentResult(row, comment.id),
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: comment.authorUserId,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
});
|
|
if (updated) expired.push(hydrateInteraction(updated));
|
|
}
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
await touchIssue(db, issue.id);
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return expired;
|
|
},
|
|
|
|
expireStaleRequestConfirmationsForIssueDocument: async (
|
|
issue: { id: string; companyId: string },
|
|
document: {
|
|
id: string;
|
|
key: string;
|
|
latestRevisionId?: string | null;
|
|
latestRevisionNumber?: number | null;
|
|
} | null,
|
|
actor: InteractionActor,
|
|
) => {
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
inArray(issueThreadInteractions.kind, [
|
|
...TARGET_BOUND_INTERACTION_KINDS,
|
|
]),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
);
|
|
|
|
const staleRows = rows.filter((row) => {
|
|
const interaction = hydrateInteraction(row) as TargetBoundInteraction;
|
|
const target = interaction.payload.target;
|
|
if (!target || target.type !== "issue_document") return false;
|
|
const targetIssueId = target.issueId ?? issue.id;
|
|
if (targetIssueId !== issue.id) return false;
|
|
if (document && target.documentId && target.documentId !== document.id)
|
|
return false;
|
|
if (document && target.key !== document.key) return false;
|
|
if (!document) return true;
|
|
return (
|
|
target.revisionId !== document.latestRevisionId ||
|
|
(target.revisionNumber != null &&
|
|
target.revisionNumber !== document.latestRevisionNumber)
|
|
);
|
|
});
|
|
|
|
if (staleRows.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const expired: IssueThreadInteraction[] = [];
|
|
for (const row of staleRows) {
|
|
const interaction = hydrateInteraction(row) as TargetBoundInteraction;
|
|
const target = interaction.payload.target ?? null;
|
|
const currentTarget = buildIssueDocumentTargetFromDocument({
|
|
issueId: issue.id,
|
|
document,
|
|
});
|
|
const updated = await db.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
if (row.kind === "request_confirmation") await notifyChatRunMilestones(tx, row);
|
|
const [resolved] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
payload: currentTarget
|
|
? {
|
|
...interaction.payload,
|
|
target: currentTarget,
|
|
}
|
|
: interaction.payload,
|
|
result: buildStaleTargetResult(row, target),
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!resolved) return null;
|
|
const expiredInteraction = hydrateInteraction(resolved);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
expiredInteraction,
|
|
);
|
|
return expiredInteraction;
|
|
});
|
|
if (updated) expired.push(updated);
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
await touchIssue(db, issue.id);
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return expired;
|
|
},
|
|
|
|
expireConnectionIntentsForOwnershipChange: async (issue: { id: string; companyId: string }) => {
|
|
// A pending connection_intent asks for either an AI account or a tool
|
|
// account, and the two leave different things broken. One bulk write gave
|
|
// every row the AI wording, so an expired Notion request told the reader
|
|
// the AI account was missing. Read the pending rows first and name the
|
|
// subject each row actually asked for.
|
|
const pending = await db
|
|
.select({ id: issueThreadInteractions.id, payload: issueThreadInteractions.payload })
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.kind, "connection_intent"),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
));
|
|
const expired: typeof issueThreadInteractions.$inferSelect[] = [];
|
|
for (const row of pending) {
|
|
const payload = row.payload as { purpose?: string; serviceName?: string } | null;
|
|
const subject = payload?.purpose === "ai"
|
|
? "the AI account"
|
|
: `the ${payload?.serviceName?.trim() || "requested"} connection`;
|
|
const updated = await db.transaction(async tx => {
|
|
await notifyChatConfirmationResolution(tx, { companyId: issue.companyId, issueId: issue.id, kind: "connection_intent" });
|
|
const [resolved] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: {
|
|
version: 1,
|
|
outcome: "expired",
|
|
reason: `The task was reassigned before the connection request was answered, so ${subject} is still not connected. Reconnect it or choose an available connection.`,
|
|
},
|
|
resolvedAt: now(),
|
|
updatedAt: now(),
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
))
|
|
.returning();
|
|
if (resolved) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(resolved));
|
|
return resolved;
|
|
});
|
|
if (updated) expired.push(updated);
|
|
}
|
|
if (expired.length) await db.delete(toolOauthStates).where(inArray(toolOauthStates.interactionId, expired.map((item) => item.id)));
|
|
return expired;
|
|
},
|
|
expirePendingInteractionsForTerminalIssue: async (
|
|
issue: { id: string; companyId: string; status: string },
|
|
actor: InteractionActor = {},
|
|
) => {
|
|
if (!isTerminalIssueStatus(issue.status)) return [];
|
|
const rows = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
// Completed work retains ordinary historical questions for later
|
|
// human answers. Cancellation and governed requests still expire.
|
|
issue.status === "done" ? sql`not ${historicalQuestionCondition()}` : undefined,
|
|
),
|
|
);
|
|
if (rows.length === 0) return [];
|
|
|
|
const now = new Date();
|
|
const expired: IssueThreadInteraction[] = [];
|
|
for (const row of rows) {
|
|
// Same ordering as withdrawal: revoke the linked tool action before
|
|
// resolving the card, inside one transaction. A concurrent gateway
|
|
// claim (approved -> executing) blocks on the revocation's row lock
|
|
// and then aborts; if the card was concurrently resolved instead, the
|
|
// no-row update below rolls the revocation back. A claim that already
|
|
// committed is in flight and cannot be recalled — the card still
|
|
// expires and the execution result lands on it via the gateway's
|
|
// lifecycle reflection.
|
|
const updated = await db
|
|
.transaction(async (tx) => {
|
|
await notifyChatConfirmationResolution(tx, row);
|
|
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
|
if (row.kind === "connection_intent") {
|
|
await tx
|
|
.delete(toolOauthStates)
|
|
.where(eq(toolOauthStates.interactionId, row.id));
|
|
}
|
|
await resolveLinkedToolActionRequests(tx, row, {
|
|
status: "expired",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor,
|
|
now,
|
|
});
|
|
await resolveLinkedSecretProposal(tx as unknown as Db, row, {
|
|
status: "expired",
|
|
actor,
|
|
reason: "Issue closed before the secret proposal was resolved",
|
|
now,
|
|
});
|
|
const [resolved] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "expired",
|
|
result: buildAdministrativeOutcomeResult(row, "issue_closed"),
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, row.id),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!resolved) throw new InteractionResolvedConcurrentlyError();
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(resolved),
|
|
);
|
|
return resolved;
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (err instanceof InteractionResolvedConcurrentlyError)
|
|
return null;
|
|
throw err;
|
|
});
|
|
if (updated) expired.push(hydrateInteraction(updated));
|
|
}
|
|
if (expired.length > 0) {
|
|
await touchIssue(db, issue.id);
|
|
await emitResolvedInteractionsTelemetry(db, expired);
|
|
}
|
|
return expired;
|
|
},
|
|
|
|
withdrawInteraction: async (
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
input: WithdrawIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const data = withdrawIssueThreadInteractionSchema.parse(input);
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (
|
|
!current ||
|
|
current.companyId !== issue.companyId ||
|
|
current.issueId !== issue.id
|
|
) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (current.status !== "pending") throw interactionTerminalError(current);
|
|
|
|
const reason = data.reason?.trim() || null;
|
|
const now = new Date();
|
|
// One transaction, linked tool action first: revoking pending/approved
|
|
// requests before resolving the card means a concurrent gateway claim
|
|
// (approved -> executing) either loses to the revocation's row lock or
|
|
// is detected below and aborts the withdrawal; a concurrent card
|
|
// resolution rolls the revocation back via the status="pending" guard.
|
|
// "approved" is revoked too — the request can be approved from the tool
|
|
// review queue while the card is still pending, and an executable
|
|
// request must not outlive a withdrawn card.
|
|
const updated = await db.transaction(async (tx) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
await notifyChatConfirmationResolution(tx, current);
|
|
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
|
|
await resolveLinkedToolActionRequests(tx, current, {
|
|
status: "cancelled",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor,
|
|
now,
|
|
});
|
|
await resolveLinkedSecretProposal(tx as unknown as Db, current, {
|
|
status: "withdrawn",
|
|
actor,
|
|
reason,
|
|
now,
|
|
});
|
|
if (current.kind === "request_confirmation") {
|
|
const active = await tx
|
|
.select({ id: toolActionRequests.id })
|
|
.from(toolActionRequests)
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.companyId, current.companyId),
|
|
eq(toolActionRequests.interactionId, current.id),
|
|
inArray(toolActionRequests.status, ["executing", "executed"]),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (active)
|
|
throw conflict(
|
|
"The linked tool action is already executing and can no longer be withdrawn",
|
|
);
|
|
}
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "cancelled",
|
|
result: buildAdministrativeOutcomeResult(
|
|
current,
|
|
"withdrawn",
|
|
reason,
|
|
),
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!row) throw interactionAlreadyResolvedError();
|
|
const withdrawn = hydrateInteraction(row);
|
|
await mutationOptions.afterResolveInTransaction?.(tx, withdrawn);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
withdrawn,
|
|
);
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id);
|
|
const withdrawn = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, withdrawn);
|
|
return withdrawn;
|
|
},
|
|
|
|
answerQuestions: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: RespondIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!current) throw interactionNotFoundError();
|
|
if (
|
|
current.companyId !== issue.companyId ||
|
|
current.issueId !== issue.id
|
|
) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
if (current.kind !== "ask_user_questions") {
|
|
throw unprocessable(
|
|
"Only ask_user_questions interactions can be answered",
|
|
);
|
|
}
|
|
if (current.status !== "pending") {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
|
|
const interaction = hydrateInteraction(
|
|
current,
|
|
) as AskUserQuestionsInteraction;
|
|
const normalizedAnswers = normalizeQuestionAnswers({
|
|
questions: interaction.payload.questions,
|
|
textQuestionIds: new Set((interaction.payload.questionSet?.questions ?? [])
|
|
.filter((question) => question.answerMode === "text")
|
|
.map((question) => question.id)),
|
|
answers: input.answers,
|
|
});
|
|
if (interaction.payload.questionSet) {
|
|
try {
|
|
await parseQuestionInteractionAnswers(interaction.payload.questionSet, normalizedAnswers, interaction.payload.questions);
|
|
} catch (error) {
|
|
throw unprocessable(
|
|
error instanceof Error ? error.message : "Invalid question response",
|
|
{ code: "invalid_question_response" },
|
|
);
|
|
}
|
|
}
|
|
|
|
const updated = await db.transaction(async (tx) => {
|
|
// Serialize against task completion/cancellation and use the persisted
|
|
// status, including when the caller read the task before it closed.
|
|
const [issueRow] = await tx.select({ status: issues.status }).from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId))).for("update");
|
|
if (!issueRow) throw interactionNotFoundError();
|
|
let historicalAnswer = false;
|
|
if (isTerminalIssueStatus(issueRow.status)) {
|
|
if (issueRow.status === "done" && actor.userId && !actor.agentId && !actor.runId && !actor.systemId) {
|
|
historicalAnswer = (await tx.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions)
|
|
.where(and(eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.companyId, issue.companyId), eq(issueThreadInteractions.issueId, issue.id),
|
|
historicalQuestionCondition())).limit(1)).length > 0;
|
|
}
|
|
if (!historicalAnswer) throw interactionIssueClosedError();
|
|
}
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
await notifyChatRunMilestones(tx, current);
|
|
await mutationOptions.beforeResolveInTransaction?.(tx);
|
|
const resolvedAt = new Date();
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "answered",
|
|
result: {
|
|
version: 1,
|
|
answers: normalizedAnswers,
|
|
summaryMarkdown: input.summaryMarkdown ?? null,
|
|
},
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt,
|
|
updatedAt: resolvedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
if (!row) throw interactionAlreadyResolvedError();
|
|
const answered = hydrateInteraction(row) as AskUserQuestionsInteraction;
|
|
// This answer updates conversation history only. It must not resume
|
|
// the completed source run or enqueue new work for the closed task.
|
|
if (!historicalAnswer) {
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.question);
|
|
await tx
|
|
.insert(issueQuestionResponseDeliveries)
|
|
.values(questionResponseDeliveryValues(answered));
|
|
}
|
|
// Provider callbacks use this hook to atomically claim and complete
|
|
// the action that resolved the interaction. Settle all remaining
|
|
// provider controls only after that winner is durable; otherwise the
|
|
// generic sibling-expiry pass would invalidate a Slack/Teams modal
|
|
// submit token before it can be claimed in this same transaction.
|
|
await mutationOptions.afterResolveInTransaction?.(tx, answered);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
answered,
|
|
);
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id);
|
|
const answered = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, answered);
|
|
return answered;
|
|
},
|
|
|
|
dismissInteraction: async (
|
|
issue: { id: string; companyId: string },
|
|
interactionId: string,
|
|
actor: InteractionActor,
|
|
) => {
|
|
if (!actor.userId || actor.agentId || actor.runId || actor.systemId) {
|
|
throw forbidden("Only a human can dismiss a request");
|
|
}
|
|
return db.transaction(async (tx) => {
|
|
// Use the same lock order as resolution and completion. Dismissal only
|
|
// changes attention state; it never resolves or executes the request.
|
|
const [task] = await tx.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
status: issues.status,
|
|
executionRunId: issues.executionRunId,
|
|
workMode: issues.workMode,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
reviewPolicy: issues.reviewPolicy,
|
|
createdByAgentId: issues.createdByAgentId,
|
|
createdByUserId: issues.createdByUserId,
|
|
}).from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId))).for("update");
|
|
if (!task) throw interactionNotFoundError();
|
|
const [current] = await tx.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.id, interactionId), eq(issueThreadInteractions.companyId, issue.companyId),
|
|
eq(issueThreadInteractions.issueId, issue.id),
|
|
)).for("update");
|
|
if (!current) throw interactionNotFoundError();
|
|
assertInteractionResolutionAllowed(current, actor);
|
|
if (current.status !== "pending" || current.dismissedAt) return hydrateInteraction(current);
|
|
if (task.status === "in_review" && isRequestConfirmationLikeKind(current.kind)) {
|
|
await assertRequestConfirmationResolutionAllowedUnderLock(tx as unknown as Db, task, current, actor);
|
|
}
|
|
const now = new Date();
|
|
const [updated] = await tx.update(issueThreadInteractions)
|
|
.set({ dismissedAt: now, dismissedByUserId: actor.userId, updatedAt: now })
|
|
.where(eq(issueThreadInteractions.id, interactionId)).returning();
|
|
return hydrateInteraction(updated!);
|
|
});
|
|
},
|
|
|
|
skipInteraction: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: SkipIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const data = skipIssueThreadInteractionSchema.parse(input);
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (
|
|
!current ||
|
|
current.companyId !== issue.companyId ||
|
|
current.issueId !== issue.id
|
|
) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (current.status !== "pending") throw interactionTerminalError(current);
|
|
|
|
const reason = data.reason?.trim() || null;
|
|
const now = new Date();
|
|
const updated = await db.transaction(async (tx) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
await notifyChatConfirmationResolution(tx, current);
|
|
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
|
|
await resolveLinkedToolActionRequests(tx, current, {
|
|
status: "cancelled",
|
|
fromStatuses: ["pending", "approved"],
|
|
actor,
|
|
now,
|
|
});
|
|
await resolveLinkedSecretProposal(tx as unknown as Db, current, {
|
|
status: "withdrawn",
|
|
actor,
|
|
reason: reason ?? "Skipped from the task composer",
|
|
now,
|
|
});
|
|
|
|
if (current.kind === "request_confirmation") {
|
|
const active = await tx
|
|
.select({ id: toolActionRequests.id })
|
|
.from(toolActionRequests)
|
|
.where(
|
|
and(
|
|
eq(toolActionRequests.companyId, current.companyId),
|
|
eq(toolActionRequests.interactionId, current.id),
|
|
inArray(toolActionRequests.status, ["executing", "executed"]),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (active)
|
|
throw conflict(
|
|
"The linked tool action has begun executing and can no longer be skipped",
|
|
);
|
|
}
|
|
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "cancelled",
|
|
result: buildSkippedOutcomeResult(current, reason),
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!row) throw interactionAlreadyResolvedError();
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
hydrateInteraction(row),
|
|
);
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id);
|
|
const skipped = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, skipped);
|
|
return skipped;
|
|
},
|
|
|
|
cancelQuestions: async (
|
|
issue: { id: string; companyId: string; status?: string },
|
|
interactionId: string,
|
|
input: CancelIssueThreadInteraction,
|
|
actor: InteractionActor,
|
|
mutationOptions: InteractionResolutionMutationOptions = {},
|
|
) => {
|
|
assertIssueOpenForInteractionResolution(issue);
|
|
const data = cancelIssueThreadInteractionSchema.parse(input);
|
|
const current = await db
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, interactionId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!current) throw interactionNotFoundError();
|
|
if (
|
|
current.companyId !== issue.companyId ||
|
|
current.issueId !== issue.id
|
|
) {
|
|
throw interactionNotFoundError();
|
|
}
|
|
if (current.kind !== "ask_user_questions") {
|
|
throw unprocessable(
|
|
"Only ask_user_questions interactions can be cancelled",
|
|
);
|
|
}
|
|
if (current.status !== "pending") {
|
|
throw interactionTerminalError(current);
|
|
}
|
|
|
|
const reason = data.reason?.trim() || null;
|
|
const updated = await db.transaction(async (tx) => {
|
|
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
|
await notifyChatRunMilestones(tx, current);
|
|
const resolvedAt = new Date();
|
|
const [row] = await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "cancelled",
|
|
result: {
|
|
version: 1,
|
|
answers: [],
|
|
cancelled: true,
|
|
cancellationReason: reason,
|
|
summaryMarkdown: null,
|
|
},
|
|
resolvedByAgentId: actor.agentId ?? null,
|
|
resolvedByRunId: actor.runId ?? null,
|
|
resolvedByUserId: actor.userId ?? null,
|
|
resolvedAt,
|
|
updatedAt: resolvedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, interactionId),
|
|
eq(issueThreadInteractions.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
|
|
if (!row) throw interactionAlreadyResolvedError();
|
|
const cancelled = hydrateInteraction(row);
|
|
await mutationOptions.afterResolveInTransaction?.(tx, cancelled);
|
|
await enqueueTerminalIssueInteractionChatPublications(
|
|
tx as unknown as Db,
|
|
cancelled,
|
|
);
|
|
return row;
|
|
});
|
|
|
|
await touchIssue(db, issue.id);
|
|
const cancelled = hydrateInteraction(updated);
|
|
await emitInteractionResolvedTelemetry(db, cancelled);
|
|
return cancelled;
|
|
},
|
|
};
|
|
}
|