From fc525c355d741ea5478dd1b106526b5b8205de1b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:36:16 -0400 Subject: [PATCH 01/43] refactor(mobile): send the task workspace-creation domain through typed RpcOperations (#20568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(mobile): record main's task workspace-creation RPC behaviour before migrating it 28 scenarios over nine task senders, recorded from main so the step-4 migration of the workspace-creation half of src/tasks/ has a frozen answer to compare against. Four senders mount as plain exported functions; three are model-chained hooks mounted the way the settings adapters mount theirs. The 153 existing goldens change header-only (`baseline`, `recorderSha256`): any new scenario re-digests the recorder, and the pinned baseline had drifted from main because the source-control migration landed. Content is byte-identical on all 153 — verified field-by-field against HEAD. `operation-module-loader.ts` now shares src/transport/rpc-delivery-ambiguity.ts with mounted modules instead of evaluating a second copy. The mark is a WeakSet keyed on the rejection object, so the copy the loader built had an empty registry and every delivery-unknown rejection read as a definite failure inside the operation under test — worktree.create's whole replay path was unreachable. With one registry, `tw-create-retry-ambiguous-after-drop` records the create still pending at the reconnect wait and abandoning at exactly 20000 ms, while the unstamped-create scenario records the same rejection surfacing at 0 ms. No existing golden moves: no other mounted module consumes the mark. `task-preferences-optimistic` is re-anchored above the send rather than across it, so migrating this file does not have to move the anchor. It still kills, and for the same reason: the preset the screen shows no longer follows the tap. Scenarios deliberately pin the empty-message refusals (`*-refused-empty-message`, `*-empty-message`), because a refusal with no message falls back to the screen's copy while a transport error with no message does not, and the two paths are easy to collapse when a call site moves behind an acceptance policy. Goldens: 153 -> 201, 2.9M -> 3.7M. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): format the recording manifest and re-digest the goldens `oxfmt --check` from mobile/ collapses a one-element `sites` array in each new scenario. The JSON value is unchanged — verified by comparing both files parsed and key-sorted — but the manifest is inside `recorderSha256`, so all 201 goldens carry a new digest. Every other field, header and observation alike, is byte-identical. Re-recorded in a separate worktree at the previous commit so the goldens stay attributable to main's product source rather than to the migration that follows. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): separate the goldens from the migration, and re-digest The previous commit accidentally carried the product migration alongside the manifest format, which both broke the commit that is supposed to prove parity and left the suite red: the digest was recorded without a comment move that a lint fix had made inside the adapter, so all 201 goldens failed their `recorderSha256` header. This backs the product half straight out again — the next commit re-applies it byte-for-byte — and re-records from the pinned baseline in a separate worktree carrying this branch's recorder, per the procedure in the recording README. Every field except `recorderSha256` is byte-identical to the previous commit's goldens on all 201 files, so no observation moved in either direction. The suite is green here with main's product source, which is what makes the next commit's "no golden changed" claim mean something. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the task workspace-creation domain through typed RpcOperations 12 of src/tasks/'s 37 raw-port files now send through a declared operation instead of the raw request port: 36 references to 0, leaving 25 files and 73 references for the provider item/detail/mutation half. No golden moved — `git show --stat` on this commit touches nothing under mobile/rpc-foundation/, which is the parity claim. Twenty-two operations over twenty methods, in four modules named for what they send: workspace create (create, PR/MR base resolution, create-time capabilities), workspace source (SSH connect/state, agent detection, repo hooks, sparse presets, ref search), task runtime (status, ui.get/ui.set, preflight, Linear status, settings.update) and the Smart picker's provider reads. Two methods carry two policies each, and both pairs are named. `status.get`: the Tasks screen cannot hydrate without it and surfaces the host's message, while create-time capability probing degrades to "no capabilities" and creates anyway — so one throws on refusal and one skips. `ui.set`: two sites await it, one is fire-and-forget and never interprets the reply at all. Both pairs share one reader, so no method has two readers. No new acceptance policy. worktree.create keeps its delivery-unknown contract. `request` returns the transport promise itself, so the retry loop catches the object the transport marked; two new tests assert `toBe(marked)` in one direction and that an unmarked rejection stays unmarked in the other, because a mark added on the way out would replay a create the host never received. `tw-create-retry-ambiguous-after-drop` records the create still pending at the reconnect wait and abandoning at exactly 20000 ms. Three sites still read the raw refusal envelope before interpreting, because the code or the message decides the route and no acceptance policy carries either through: the create retry needs the message for `isRetryableWorktreeCreateConflict`, and the paste lookup needs `method_not_found` to retire the slug probe host-wide. Both are documented at the site. The hydration barrier keeps raw requests inside its `Promise.all`. main's group rejects as soon as one leg rejects; `startRpcOperation` + `interpretAtRpcBarrier` would wait for the slowest peer and let a later policy surface a different error. Interpretation stays after the `stale` guard, where it was. `WorkspaceCreateParams` is now `RpcSendParams<'worktree.create'>` rather than `Record`, which types the builder and the operation together; every field the three builders already sent typechecks against the host schema unchanged. `RpcSendArguments` now also makes params optional for a method whose params type has no required field, because `preflight.check` is such a method and main sent it none — requiring `{}` would have put a new object on the wire. The Mobile Tasks source-parity hashes move for the same reason bound settings requests moved them: the method string and the envelope read leave the screen. The signature diff is evidence rather than a re-pin — `semantics` is a pure deletion of 22 `rpc:` call signatures and 22 method literals with nothing added, statement/declaration/render/style counts are unchanged, and render tokens, styles and declarations are byte-identical. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the task workspace adapters at the sender/hook seam The single adapter file reached 344 lines against mobile's 300-line limit. CI lints every file, so this is red there even though the changed-code gate does not report it. Split along the seam the recording README already draws: exported async senders that take a client and need no React host, and the drawer's three model-chained hooks. No adapter body changed. Both files are inside `recorderSha256`, so all 201 goldens carry a new digest. Every other field is byte-identical, verified file by file. Re-recorded from the pinned baseline in a separate worktree carrying this branch's recorder, so the goldens stay attributable to main's product source. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the one-key unchecked reader a name Four readers were the same three lines: read one property off the reply, wrap it unchecked. `rpcUncheckedMemberReader` is the one-key sibling of the existing `rpcUncheckedPayloadReader`, so the annotation and the closure go away at each site. The pilot's `commitCompareEntriesReader` is converted too, so the helper has no longhand twin left to copy from. No behaviour change: the helper composes the same `rpcReadUnchecked` over `rpcPayloadMember`, including the property-read exception on a null result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the local arm of workspace agent detection `preflight.detectAgents` was the one migrated operation with no recorded coverage: the ssh adapter hardcoded `connectionId: 'ssh-1'`, so the detection effect's ternary only ever took the remote arm and the local call site could be repointed at another method without a golden noticing. The adapter now takes the connectionId as a parameter and registers twice; `tasks.workspace-ssh-local` mounts the same hook with no connection, which is the only difference the effect branches on. Recorded at the pinned baseline with this branch's recorder laid over it, so the new golden is main's behaviour and the migrated code has to reproduce it — it does. Goldens: two added (`tw-workspace-ssh-local-agents` and its reply matrix). The other 201 changed on `recorderSha256` only, because the adapter edit moves the recorder digest every golden pins. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drive workspace create and the Linear list to a recorded wire Two operations passed a policy swap unnoticed, both because no golden reached their acceptance branch. `worktree.create`: the create hook's fixture resolved setup to a prompt, so all three settings.task-workspace scenarios stopped before the request and the only consumer that hands a refusal to interpret was never recorded. The adapter now takes the setup resolution as a parameter and registers a second family that resolves it, so createWorkspace runs to the wire. Two scenarios: a Linear item that creates directly, and a GitHub pull request that resolves its base first, which also puts this hook's built params — start point, generated display name, agent launch fields — in a golden for the first time. The existing prompt family is untouched, so its recordings still pin that branch. `linear.listIssues`: it appeared only in a non-base scenario, and the matrix reads the family base, so the family had no partition for it. The base now lists assigned issues after searching. Goldens: five added. Five moved beyond the digest, all derived from the smart-search base that gained the list leg. The other 198 changed on `recorderSha256` only. Recorded at the pinned baseline with this branch's recorder laid over it, so every new golden is main's behaviour. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the unreachable unmount branches from the task adapters Nothing dispatches `unmount` to these three adapters: the only producer is `lifecycleSchedules`, driven from a hardcoded five-id list that names no task-workspace family, and it pushes a `remount` right after, which these adapters would throw on. The branch read as lifecycle coverage that was never wired up. `dispose: hook.unmount` already tears the mount down. Goldens re-recorded at the pinned baseline because the recorder digest moved; `recorderSha256` is the only line that changed in all 208. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens at main's post-squash baseline Recorded from a detached checkout of e53f1557e1 (main's unmigrated product code) with this branch's recorder laid over it, so the parity claim stays non-circular. - `baseline` repinned to e53f1557e1 on all 208 goldens; main pinned 5ec0b2698f, a pre-squash branch commit not reachable from main. - `recorderSha256` moved on all 208 because this branch's adapters are in the whole-manifest digest. - 55 task-workspace goldens re-recorded at the new baseline. - No other line in any of main's 153 goldens changed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens under #20562's per-scenario digest Baseline repinned to 50e752fc66 and all 208 goldens recorded from that commit's unmigrated product tree with this branch's recorder laid over it. recorderSha256 moves on every golden because the task-workspace adapters live in the recorder directory. scenarioSha256 does not move on any of main's 153: the manifest only adds 31 scenarios and edits none, which is the property #20562 was built to give. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- mobile/rpc-foundation/goldens/b1.json | 4 +- mobile/rpc-foundation/goldens/b2.json | 4 +- mobile/rpc-foundation/goldens/b3.json | 4 +- .../interruptions-inventory-lifecycle.json | 4 +- ...ions-settings-bot-overrides-fulfilled.json | 4 +- .../goldens/inventory-lifecycle.json | 4 +- .../goldens/inventory-repeat-query.json | 4 +- .../rpc-foundation/goldens/lifecycle-b3.json | 4 +- .../lifecycle-inventory-lifecycle.json | 4 +- ...ycle-settings-bot-overrides-fulfilled.json | 4 +- ...cle-settings-task-hydration-fulfilled.json | 4 +- ...-settings-workspace-context-fulfilled.json | 4 +- ....base-ref-chain-repo.baserefdefault-1.json | 4 +- ...matrix-git.base-ref-chain-repo.list-1.json | 4 +- ...ix-git.base-ref-chain-worktree.show-1.json | 4 +- ...essage-ai-git.generatecommitmessage-1.json | 4 +- ...matrix-git.history-read-git.history-1.json | 4 +- ...ix-git.remote-prerequisite-git.push-1.json | 4 +- ...x-git.review-preparation-git.status-1.json | 4 +- ...-hostedreview.create-chain-git.push-1.json | 4 +- ...ew.create-chain-hostedreview.create-1.json | 4 +- ...tedreview.create-chain-worktree.set-1.json | 4 +- ...dreview.create-intent-git.bulkstage-1.json | 4 +- ...stedreview.create-intent-git.commit-1.json | 4 +- ...te-intent-git.generatecommitmessage-1.json | 4 +- ...hostedreview.create-intent-git.push-1.json | 4 +- ...stedreview.create-intent-git.status-1.json | 4 +- ...stedreview.create-intent-git.status-2.json | 4 +- ...stedreview.create-intent-git.status-3.json | 4 +- ...stedreview.create-intent-git.status-4.json | 4 +- ...w.create-intent-hostedreview.create-1.json | 4 +- ...hostedreview.getcreationeligibility-1.json | 4 +- ...hostedreview.getcreationeligibility-2.json | 4 +- ...edreview.create-intent-worktree.set-1.json | 4 +- ...hostedreview.getcreationeligibility-1.json | 4 +- ...-legacy-inventory-files.searchpaths-1.json | 4 +- ...-legacy-inventory-files.searchpaths-2.json | 4 +- ...trix-legacy-inventory-fresh-inventory.json | 4 +- ...matrix-legacy-inventory-old-inventory.json | 4 +- ...near-detail-barrier-linear.getissue-1.json | 4 +- ...detail-barrier-linear.issuecomments-1.json | 4 +- ...se-github.project.updateissuebyslug-1.json | 4 +- ...on.tab-reveal-session.tabs.activate-1.json | 4 +- ...ession.tab-reveal-session.tabs.list-1.json | 4 +- ...t-read-preflight.detectremoteagents-1.json | 4 +- ...atrix-settings-agent-read-repo.list-1.json | 4 +- ...ix-settings-agent-read-settings.get-1.json | 4 +- ...ettings-best-effort-settings.update-1.json | 4 +- ...settings.bot-overrides-settings.get-1.json | 4 +- ...ttings.home-providers-linear.status-1.json | 4 +- ...ings.home-providers-preflight.check-1.json | 4 +- ...ettings.home-providers-settings.get-1.json | 4 +- ...ettings.repo-metadata-host.platform-1.json | 4 +- ...ix-settings.repo-metadata-repo.list-1.json | 4 +- ...settings.repo-metadata-settings.get-1.json | 4 +- ...po-metadata-ssh.listtargetsummaries-1.json | 4 +- ...esume-metadata-folderworkspace.list-1.json | 4 +- ...s.resume-metadata-projectgroup.list-1.json | 4 +- ...-settings.resume-metadata-repo.list-1.json | 4 +- ...ttings.resume-metadata-settings.get-1.json | 4 +- ...ettings.resume-metadata-worktree.ps-1.json | 4 +- ...ttings.task-hydration-linear.status-1.json | 4 +- ...ings.task-hydration-preflight.check-1.json | 4 +- ...ettings.task-hydration-settings.get-1.json | 4 +- ...-settings.task-hydration-status.get-1.json | 4 +- ...trix-settings.task-hydration-ui.get-1.json | 4 +- ....task-workspace-create-settings.get-1.json | 860 ++++++++ ...sk-workspace-create-worktree.create-1.json | 1010 +++++++++ ...ettings.task-workspace-settings.get-1.json | 4 +- ...ngs.workspace-context-linear.status-1.json | 4 +- ...s.workspace-context-preflight.check-1.json | 4 +- ...ings.workspace-context-settings.get-1.json | 4 +- ...x-settings.workspace-context-ui.get-1.json | 4 +- ...tings.workspace-submit-settings.get-1.json | 4 +- ...-tasks.paste-lookup-github.reposlug-1.json | 1081 ++++++++++ ...-tasks.paste-lookup-github.workitem-1.json | 1645 ++++++++++++++ ...e-lookup-github.workitembyownerrepo-1.json | 1513 +++++++++++++ ....paste-lookup-gitlab.workitembypath-1.json | 1319 ++++++++++++ ...-source-search-github.listworkitems-1.json | 1885 +++++++++++++++++ ...-source-search-gitlab.listworkitems-1.json | 1772 ++++++++++++++++ ...art-source-search-linear.listissues-1.json | 1199 +++++++++++ ...t-source-search-linear.searchissues-1.json | 1495 +++++++++++++ ...smart-source-search-repo.searchrefs-1.json | 1410 ++++++++++++ ...ks.workspace-source-repo.searchrefs-1.json | 976 +++++++++ ...workspace-source-repo.sparsepresets-1.json | 1265 +++++++++++ ...kspace-sparse-repo.savesparsepreset-1.json | 940 ++++++++ ...tasks.workspace-sparse-ssh.getstate-1.json | 1164 ++++++++++ ...ce-ssh-local-preflight.detectagents-1.json | 566 +++++ ...ce-ssh-preflight.detectremoteagents-1.json | 1268 +++++++++++ ...trix-tasks.workspace-ssh-repo.hooks-1.json | 975 +++++++++ ...rix-tasks.workspace-ssh-ssh.connect-1.json | 1421 +++++++++++++ ...rktree.create-retry-worktree.create-1.json | 652 ++++++ ....hosted-base-worktree.resolvemrbase-1.json | 738 +++++++ ....hosted-base-worktree.resolveprbase-1.json | 868 ++++++++ ...x-worktree.review-link-worktree.set-1.json | 4 +- ...ree.runtime-capabilities-status.get-1.json | 570 +++++ ...ix-worktree.setup-hook-trust-ui.set-1.json | 673 ++++++ .../goldens/probe-new-tab-both-refused.json | 4 +- .../probe-new-tab-null-sibling-refused.json | 4 +- ...probe-new-tab-refused-sibling-rejects.json | 4 +- ...probe-new-tab-rejects-sibling-refused.json | 4 +- .../goldens/sc-base-ref-default.json | 4 +- .../goldens/sc-base-ref-repo-fallback.json | 4 +- .../goldens/sc-base-ref-unavailable.json | 4 +- .../goldens/sc-base-ref-worktree-hit.json | 4 +- .../sc-commit-message-cancel-rejected.json | 4 +- .../goldens/sc-commit-message-canceled.json | 4 +- .../goldens/sc-commit-message-generated.json | 4 +- .../goldens/sc-create-existing-review.json | 4 +- ...reate-intent-stage-commit-push-create.json | 4 +- .../sc-create-link-failure-is-non-fatal.json | 4 +- .../sc-create-pushes-then-creates.json | 4 +- .../sc-create-refused-empty-message.json | 4 +- .../sc-create-rejected-empty-message.json | 4 +- .../goldens/sc-eligibility-fetched.json | 4 +- .../goldens/sc-history-loaded.json | 4 +- .../goldens/sc-pr-link-hosted-review.json | 4 +- .../goldens/sc-pr-link-read.json | 4 +- .../goldens/sc-pr-link-set.json | 4 +- .../sc-prefill-unavailable-on-refusal.json | 4 +- .../sc-prefill-unavailable-on-rejection.json | 4 +- .../sc-prerequisite-force-with-lease.json | 4 +- .../goldens/sc-prerequisite-publish.json | 4 +- .../goldens/sc-prerequisite-push.json | 4 +- .../goldens/sc-prerequisite-skipped.json | 4 +- .../goldens/sc-reveal-first-poll.json | 4 +- .../goldens/sc-reveal-timeout.json | 4 +- .../sc-review-commit-inner-failure.json | 4 +- ...c-review-commit-refused-empty-message.json | 4 +- .../goldens/sc-review-commit-rejected.json | 4 +- .../goldens/sc-review-commit.json | 4 +- .../sc-review-status-entries-not-array.json | 4 +- .../goldens/sc-review-status-normalized.json | 4 +- .../rpc-foundation/goldens/schedules-b3.json | 4 +- ...les-settings-home-providers-fulfilled.json | 4 +- .../schedules-settings-new-tab-ssh.json | 4 +- ...ules-settings-repo-metadata-fulfilled.json | 4 +- ...es-settings-resume-metadata-fulfilled.json | 4 +- ...les-settings-task-hydration-fulfilled.json | 4 +- ...-settings-workspace-context-fulfilled.json | 4 +- .../settings-bot-overrides-fulfilled.json | 4 +- ...ettings-bot-overrides-refresh-refused.json | 4 +- .../settings-bot-overrides-refused.json | 4 +- ...ettings-bot-overrides-transport-error.json | 4 +- .../goldens/settings-home-coalesced.json | 4 +- .../settings-home-providers-fulfilled.json | 4 +- ...ings-home-providers-refuse-after-data.json | 4 +- .../settings-home-providers-refused.json | 4 +- ...ttings-home-providers-transport-error.json | 4 +- .../goldens/settings-new-tab-refused.json | 4 +- .../goldens/settings-new-tab-ssh.json | 4 +- .../settings-new-tab-transport-error.json | 4 +- .../goldens/settings-repo-cache-expiry.json | 4 +- .../settings-repo-metadata-fulfilled.json | 4 +- ...tings-repo-metadata-refuse-after-data.json | 4 +- .../settings-repo-metadata-refused.json | 4 +- .../settings-repo-metadata-single-host.json | 4 +- ...ettings-repo-metadata-transport-error.json | 4 +- .../settings-resume-metadata-fulfilled.json | 4 +- ...ngs-resume-metadata-refuse-after-data.json | 4 +- .../settings-resume-metadata-refused.json | 4 +- ...tings-resume-metadata-transport-error.json | 4 +- .../settings-task-hydration-fulfilled.json | 4 +- ...ings-task-hydration-refuse-after-data.json | 4 +- .../settings-task-hydration-refused.json | 4 +- ...ttings-task-hydration-transport-error.json | 4 +- ...settings-task-workspace-create-linear.json | 241 +++ ...-task-workspace-create-pr-start-point.json | 335 +++ .../settings-task-workspace-fulfilled.json | 4 +- .../settings-task-workspace-refused.json | 4 +- ...ttings-task-workspace-transport-error.json | 4 +- .../goldens/settings-task-write.json | 4 +- .../settings-workspace-context-fulfilled.json | 4 +- ...s-workspace-context-refuse-after-data.json | 4 +- .../settings-workspace-context-refused.json | 4 +- ...ngs-workspace-context-transport-error.json | 4 +- .../settings-workspace-submit-fulfilled.json | 4 +- .../settings-workspace-submit-refused.json | 4 +- ...ings-workspace-submit-transport-error.json | 4 +- .../goldens/tw-capabilities-advertised.json | 99 + .../tw-capabilities-cutover-retried.json | 185 ++ .../tw-capabilities-legacy-idempotency.json | 95 + .../tw-create-retry-ambiguous-after-drop.json | 109 + ...reate-retry-ambiguous-while-connected.json | 83 + ...e-retry-ambiguous-without-idempotency.json | 82 + .../goldens/tw-create-retry-created.json | 90 + .../tw-create-retry-name-collision.json | 176 ++ .../tw-create-retry-unretryable-refusal.json | 86 + .../goldens/tw-create-retry-warning-kept.json | 92 + .../goldens/tw-hosted-base-resolved.json | 158 ++ .../goldens/tw-hosted-base-soft-error.json | 148 ++ .../goldens/tw-paste-lookup-resolved.json | 340 +++ .../goldens/tw-paste-lookup-slug-refused.json | 135 ++ .../tw-paste-lookup-slug-unsupported.json | 133 ++ .../goldens/tw-setup-hook-trust-always.json | 90 + .../goldens/tw-setup-hook-trust-approved.json | 100 + .../tw-smart-search-all-providers.json | 489 +++++ ...tw-smart-search-gitlab-provider-error.json | 165 ++ .../tw-smart-search-linear-listed.json | 93 + .../tw-task-preferences-resume-write.json | 154 ++ .../tw-workspace-source-presets-refused.json | 136 ++ .../goldens/tw-workspace-source-presets.json | 254 +++ .../tw-workspace-sparse-missing-preset.json | 159 ++ .../goldens/tw-workspace-sparse-saved.json | 229 ++ .../tw-workspace-ssh-connect-refused.json | 277 +++ .../goldens/tw-workspace-ssh-connected.json | 319 +++ .../tw-workspace-ssh-local-agents.json | 103 + .../goldens/tw-workspace-ssh-not-ready.json | 268 +++ mobile/rpc-foundation/pilot-scenarios.json | 1618 +++++++++++++- .../mobile-git-read-operations.ts | 26 +- mobile/src/tasks/blank-workspace-create.ts | 3 +- .../src/tasks/composer-source-base-resolve.ts | 22 +- .../tasks/mobile-task-runtime-operations.ts | 87 + .../mobile-task-source-search-operations.ts | 100 + .../mobile-tasks-refactor-parity.test.ts | 21 +- .../mobile-workspace-create-operations.ts | 64 + .../mobile-workspace-source-operations.ts | 101 + mobile/src/tasks/setup-hook-trust.ts | 8 +- mobile/src/tasks/smart-source-paste-intent.ts | 45 +- .../src/tasks/smart-source-search-requests.ts | 75 +- mobile/src/tasks/source-workspace-create.ts | 7 +- ...e-mobile-tasks-client-settings-actions.tsx | 29 +- .../use-mobile-tasks-runtime-hydration.tsx | 57 +- ...-mobile-tasks-workspace-create-actions.tsx | 47 +- ...-mobile-tasks-workspace-source-effects.tsx | 32 +- ...-mobile-tasks-workspace-sparse-actions.tsx | 24 +- .../use-mobile-tasks-workspace-ssh-state.tsx | 64 +- mobile/src/tasks/workspace-create-params.ts | 4 +- .../src/tasks/worktree-create-capability.ts | 11 +- .../src/tasks/worktree-create-retry.test.ts | 45 +- mobile/src/tasks/worktree-create-retry.ts | 17 +- .../rpc-recording/operation-module-loader.ts | 10 + .../rpc-recording/operation-mutations.ts | 13 +- .../rpc-recording/pilot-mount-adapters.ts | 10 + .../task-workspace-hook-mount-adapters.ts | 193 ++ .../task-workspace-sender-mount-adapters.ts | 180 ++ .../workspace-settings-mounts.ts | 138 +- mobile/src/transport/rpc-operation.ts | 12 +- mobile/src/transport/rpc-reader-payload.ts | 8 + .../unvalidated-rpc-request-port-inventory.ts | 23 +- 240 files changed, 35768 insertions(+), 626 deletions(-) create mode 100644 mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json create mode 100644 mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json create mode 100644 mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json create mode 100644 mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json create mode 100644 mobile/rpc-foundation/goldens/tw-capabilities-advertised.json create mode 100644 mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json create mode 100644 mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json create mode 100644 mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json create mode 100644 mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json create mode 100644 mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json create mode 100644 mobile/rpc-foundation/goldens/tw-create-retry-created.json create mode 100644 mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json create mode 100644 mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json create mode 100644 mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json create mode 100644 mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json create mode 100644 mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json create mode 100644 mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json create mode 100644 mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json create mode 100644 mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json create mode 100644 mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json create mode 100644 mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json create mode 100644 mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json create mode 100644 mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json create mode 100644 mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json create mode 100644 mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json create mode 100644 mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json create mode 100644 mobile/rpc-foundation/goldens/tw-workspace-source-presets.json create mode 100644 mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json create mode 100644 mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json create mode 100644 mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json create mode 100644 mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json create mode 100644 mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json create mode 100644 mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json create mode 100644 mobile/src/tasks/mobile-task-runtime-operations.ts create mode 100644 mobile/src/tasks/mobile-task-source-search-operations.ts create mode 100644 mobile/src/tasks/mobile-workspace-create-operations.ts create mode 100644 mobile/src/tasks/mobile-workspace-source-operations.ts create mode 100644 mobile/src/test-support/rpc-recording/task-workspace-hook-mount-adapters.ts create mode 100644 mobile/src/test-support/rpc-recording/task-workspace-sender-mount-adapters.ts diff --git a/mobile/rpc-foundation/goldens/b1.json b/mobile/rpc-foundation/goldens/b1.json index 604256d7437..6e3aee261b6 100644 --- a/mobile/rpc-foundation/goldens/b1.json +++ b/mobile/rpc-foundation/goldens/b1.json @@ -3,9 +3,9 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "23ffc912a432dcd3ff70be1903a8d518cf85634f27a2be6d21585963e338e7e3", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/b2.json b/mobile/rpc-foundation/goldens/b2.json index c919ca011f5..d53dc06a053 100644 --- a/mobile/rpc-foundation/goldens/b2.json +++ b/mobile/rpc-foundation/goldens/b2.json @@ -3,9 +3,9 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "b31992be2f91bd61fbe1b8a5400da3b7a56753564b0b0b2b38bc5d549812d693", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/b3.json b/mobile/rpc-foundation/goldens/b3.json index 78a1038358e..8fa86acb2cf 100644 --- a/mobile/rpc-foundation/goldens/b3.json +++ b/mobile/rpc-foundation/goldens/b3.json @@ -3,9 +3,9 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "130e493fcd7765e037405f59e6cc78a0cc1793b1ae092cad933ff9d5a9df8b7a", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json index 81d6adfb2f4..7e06d275855 100644 --- a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json @@ -3,9 +3,9 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "d6c57a5153d915f0a0c0fd9e305cac70b41b7eb8be226fc865227ebf1821e5d1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json index 0a1aca3b4d6..bc632784698 100644 --- a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "2d5c6dea28aa1a7bb9e4aa14a4c8441527d9ad401ad30161f05ea1f8da836bb2", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/inventory-lifecycle.json b/mobile/rpc-foundation/goldens/inventory-lifecycle.json index ad3b9c43a9a..f794d497b8e 100644 --- a/mobile/rpc-foundation/goldens/inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/inventory-lifecycle.json @@ -3,9 +3,9 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "3471f5bcd6923c7b8ba3a737bb45b5239689deb78c00e85a828f38a6d6d68a05", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/inventory-repeat-query.json b/mobile/rpc-foundation/goldens/inventory-repeat-query.json index 64a3d7492f0..8c8a21feb45 100644 --- a/mobile/rpc-foundation/goldens/inventory-repeat-query.json +++ b/mobile/rpc-foundation/goldens/inventory-repeat-query.json @@ -3,9 +3,9 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "73a468d5c7a51c2dbb7af2642f0050d05d861fce29295460c48d7c51f86bf57f", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/lifecycle-b3.json b/mobile/rpc-foundation/goldens/lifecycle-b3.json index e0d25b4724f..e847f82f811 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-b3.json +++ b/mobile/rpc-foundation/goldens/lifecycle-b3.json @@ -3,9 +3,9 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "8be12d116865d27e8dfd37921d2c723d63da101ec1197b1f5b2d9510838e1943", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index ca3b76527e9..55736257efd 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -3,9 +3,9 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "46bbafcc57fe2e3aee41a14bc26a0375b7b56e58030705fe4c28841a272b2560", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json index f43d942e36d..e55ef1e8476 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "eb80283956c93849778f23cbabf1dbf83b72744197af4f6f50335b2fc1590d87", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json index 151c59c919f..ac6785a567e 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "3a8eab831602443d320ca0aa0f35dc269d8d511e76bdae8fd025c433561d068d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json index 488a700a09a..7d9aff28b52 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "136fb1d8d5925ad12ba22f4dd6c72573a9ad03b6a6ec8308668f0d9cd71aa36d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json index 96fbed0b57d..ba3eab1b2f6 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json @@ -3,9 +3,9 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "5605a2984d7692aa80e5e38f804bdfed4b1ce8ac2102def5dc728b1a79dc1acf", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json index 347625dadf3..5f41233ede5 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json @@ -3,9 +3,9 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "ed0e97ead1aad0b45bdfc48f5fe4e498810d0cfee88f07d3c6228db56fda1dd9", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json index 2717fb396a0..41d913f3426 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json @@ -3,9 +3,9 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "1db6919b94df3b8548838ff4c206fafa3a09ea096b17c04483f78f9321ccb1ba", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json index 21171bf6d16..fe43bc5d401 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json @@ -3,9 +3,9 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "22ea5279155ecf749aaab521ffd570221ac3169b177fc1daf85ef93a49d38260", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json index abed8bf074b..529df6444b3 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json @@ -3,9 +3,9 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "86254ed87ad3427d6ee4631d7348075039ba2d4d7496d59f27f03f78580f35a1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json index 97346d0e6b4..bf6290b001a 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json @@ -3,9 +3,9 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "5009c22df7e74a850bcea41fc110ea7d7eb4bdada623837279f32eaa5149a9b8", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json index 9f417b48053..b584a8ea2f1 100644 --- a/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json @@ -3,9 +3,9 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "485b2751006ee8fb4df28b228ea7adda85779feae83974eb0f7e795e31c500a1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json index ce182135651..f216ef38c7e 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "fcf5cdc7388457156dd81fe28a470f42fbabac7435ec5572cb19e209f410ca84", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json index 0e1fbba63c7..da6223966d8 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "26e666a57805f354602a5b3906a691b10c8d6db66c77acc96c67153279c515a7", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json index c65b6cf6558..46096225d3e 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "08c25b6cb5bc12a7f67e858f229d15cf66b98b2ad4601b11f18c4c03f4a59669", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json index 8bbc14962a5..f78da080d66 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "ff9d1bfd6337607f3d3e8162692b589ecea4a32ae01b5ebb3c602f8f0a55642c", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json index f457f4b0052..58bb6579d1d 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "efeb9b248aeb98fac71c043d50afe0036cf804d3c11edfccd4e050fe8f3d8f9b", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json index 488238d3bca..10d47a76df4 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "7b0d9ddcb8df83fc4e465aa6b0dcf05aa0d8f266cd4bb8651969cb8321bcf549", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json index 20ba5f1e127..956e07af5bc 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "72c1f08739db1c0dfcd48adffaca582a3596116c1c377f95f7dab8b08b7e6cdc", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json index d4ce67f0673..5853244f4c4 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "1f002f900c1a3c92e8f7c72261579ee5015ec1529c003a1b32bcf3eaf98b672d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json index ffa68cf4114..6f62bbf402f 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "3a6487a07457e0e5aa6fc3fccfa43687acfb06d94334e621081728de937e4e8d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json index 75c0eae29c7..fcd4af54182 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "7355c45a707fa8a31f0999c4805a5b1dace4c65b727e711231f784b2f92c05ff", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json index aefc48d7680..b9208854e45 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "45cda6757b76399d282d4b07992dab21bbb8236faadedba5e92eab8818e886bf", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json index 5e25cfa990e..cf2c57ed343 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "0ca08d5e70e1780a6ee5c919491dcddb062a22623f803e9960a329825f274cbe", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json index 7fda51cd916..07de81c2d40 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "658eb7bbf63b3a4b38eca0b1733e523962b6b6943644d65aab6f5c7e62534d6a", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json index 2c3dc706047..604d0e4e0b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "75ba135731290bf734a5eef0b65f9ad8b7cac453c4e2006faac88a5da9dbe3a3", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json index 021c9f2d320..6a662ac9ef4 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "beb1161b98ffde8c5f1128e843766a1da3182d195f1f0a9012e12e5318ae01bc", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json index 04dd7052c2e..5a1d7479587 100644 --- a/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json +++ b/mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json @@ -3,9 +3,9 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "6c4de90e2617d204e82ca5e65eb17fc397acbcbb9dc0ec18594d2a7739e3528b", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json index 9d016af3ca0..436aff9d0ca 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json @@ -3,9 +3,9 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "4f6472fb7add960be9bcc8596a748264d7cb0755a782ebe9e85753ab1d1d5710", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json index 4443cb77710..c689669bdb2 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json @@ -3,9 +3,9 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "048c3ec55ec67d09d9b02e17822f1154adca577e57ffe6d3059102d552d2f759", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json index dcab97a3a4e..6249c1cc7a6 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json @@ -3,9 +3,9 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "ad458a3407e3f1303343b46a1308b43535abef2c9ed2f68db59157db5b91daa1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json index ff0a6d66acc..5a283c73742 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json @@ -3,9 +3,9 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "52742d894d0ea53db89729101664a393b10794d9c2d2fe7b40b020643a13af81", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json index 098e80e7c82..a9ba0073130 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json @@ -3,9 +3,9 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "8e00afc85e5b82d75bedecea0c748a3c8658cfc8545650e755c03f51fdc932d6", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json index 397e91d5859..967000e9bfc 100644 --- a/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json +++ b/mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json @@ -3,9 +3,9 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "40289acce4a3542773f74681d255d67cfddadf6c42317928d6728f26a76f6cfb", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json index e564c087182..9b991b64f7d 100644 --- a/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json +++ b/mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json @@ -3,9 +3,9 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "926f0d8c37a33d465bf3a04f056600cfc9f1669b1eca7e968aa1a1f797a74c61", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json index b5bfcc6c34b..8fafc9beff9 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json @@ -3,9 +3,9 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "73366226aeaec1581aeeb47219fc703917143cfd7f6a2eb01d7bd703a7c7612d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json index eb85e61870d..8b5cd2c1fab 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json @@ -3,9 +3,9 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "c38f2bc5c9faca0774dfe202137877bada9deba165c5e9c955cbe67eae0cbdd9", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json index 748dbe75ae2..7505109479a 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a0effc9a0be519ccd18c1b1abfc8b497cd3858b89ea8d345ac0f8bd6d195cf21", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json index 9946296e487..dfde62ab602 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "c21b2e0e97fab86664f634cc99d77dd587df4af4d02e6286c8380e09844096b2", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json index 2063dab2889..e6f450c3d25 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "46c4e32a921612c736c8cf45ff72ed513431c917ed3dd03f289c0ba4c28d6adb", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json index 2ab9a497475..ee5fbf6c1e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json @@ -3,9 +3,9 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "cf671da175d50a4c2e1336f4e8338c24c4752db111e1eafd226bee6ff3582b1d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json index 786467ef280..9dfa1058b3f 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json @@ -3,9 +3,9 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "01408cebcc193f8e30119381c8acf494fa5e29850fe010809deb330c2f9bcb36", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json index de544d6c810..b765e4a4eb9 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json @@ -3,9 +3,9 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "19b445b39da98d28bcbcdab6f70e47ce208ca68f165e7b62c5fe9762eee67c8d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json index 8f1903277ee..029719ef81e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json @@ -3,9 +3,9 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "4953dd0de509ce620b9840d7f460e472dc74f54d53636694d12cba2e3bb51da8", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json index 0b50cfc9f24..9eb7464df6e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json @@ -3,9 +3,9 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "70f601caeaee957bd3b172fc0fc12e85d6e2d6bed7683c86869559c6c9f25834", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json index 047c08fea63..b7ee4ec8711 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "687b109bd2bcc0c85b7c858d553e68e2fc4cb5b281d9f8b32836dbacc4bdc8f2", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json index 4909dbc80b6..39027b3569d 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "3199c745e22973b432b0a36c34bb0bdda994334a4a0cd7ad2daf8b172625ce8d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json index f2a4002299a..a12a8e3ec0e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "5e9c3ff57cf432b24a17ee046636b61b94116a687cfa506cd79dee464542b76b", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json index 6745742e69d..33488a5f028 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a88061d3d1f03074b0ed2b663b523f1602362bc317ba614106f1f646d037d6e3", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json index 4d6759cb4bb..3c390724097 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json @@ -3,9 +3,9 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "50ba7c7963cb494e4b3d484eb334977d21cc69018da57d75a7b6fd0c92860bd2", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json index 56603458116..d34f364bd50 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json @@ -3,9 +3,9 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "8dfd4550b39f0cfcb9aaa72fab0631b11f9e776ed389b206b326359d7f4c2d6e", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json index 642386c57c2..61422bd128f 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json @@ -3,9 +3,9 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "85dc526201f66409dd6a411c5e14615b82389791ec210efb9889078f5d580373", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json index de758cfca98..81cb1e44276 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json @@ -3,9 +3,9 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "080e10ae774ef097082267da0c8b6d0ebacae582d57b04a189c123258d0e5131", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json index c4874e981ea..a56126483d7 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json @@ -3,9 +3,9 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "5988fa5ce0bf6b8585f7ec66918123ee086d5cdf1185a4eeff2e88904985064c", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json index 5118bc610db..c99fdf77613 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "f5033a7a3567cc9e016bf09ac8bcd8ff381c3054c041dbccc773f7011918bf1d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json index dcd34e9ac43..33d8f68499e 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "0931b3d35868e5452cb550962f2408b6ce7cd6c89e90a9cf2897425edbb4b42d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json index 76565b7a9e0..f906996b6b9 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "d0f8d9bfe0e1469af3b0dab8b5c9799d91cc2234e72f0e031d6872059654077d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json index b248c54e214..287f8acd339 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "3f448feff463b59c3927dae020ecd8d4931bb4a6036df6d6af080de2ec5fcf2b", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json index fdee42d21bc..a889d4ef078 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "4438f9fd62876333bb980157612aaf457c5a9b9115659c8c941c3b373ad071dd", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json new file mode 100644 index 00000000000..979228bca1f --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json @@ -0,0 +1,860 @@ +{ + "operation": "settings.task-workspace-create", + "family": "settings.task-workspace-create", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "7e4c5bb29e0f630cda8a09233575b9295e485f3d3e315ebdc0458c69515fcfc7", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "067cef118d9f": { + "name": "runtimeTaskSettings", + "value": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "090c88478661": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "0f72e7ee78c9": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "worktree": { + "displayName": "ORC-1 Recorded issue", + "id": "wt-1" + } + } + } + } + }, + "0fc3e204e7ba": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "127ad2bdc042": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "180125f5d1a6": { + "name": "workspaceCreateDraft", + "value": { + "$rpc": "null" + } + }, + "2473f12c7cdd": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + } + } + } + }, + "2b3aa0da0852": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "33e3b949d4c5": { + "creating": { + "$rpc": "null" + }, + "error": "", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "3405a06dce84": { + "name": "error", + "value": "Selected agent is disabled. Choose an enabled agent before creating." + }, + "3f453dd79b03": { + "name": "workspaceAgent", + "value": "codex" + }, + "6a98511b6371": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "6eb4e79ad99a": { + "name": "setupPrompt", + "value": { + "$rpc": "null" + } + }, + "730f92993963": { + "name": "creatingKey", + "value": "linear:1" + }, + "7abdfe20af50": { + "creating": "linear:1", + "error": "", + "settings": { + "disabledTuiAgents": ["claude"] + } + }, + "7ddcb1852b39": { + "name": "settings.get#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "7ee99993a895": { + "name": "navigation", + "value": "/h/host-1/session/wt-1?name=ORC-1+Recorded+issue&created=1" + }, + "82cd71d524c8": { + "name": "error", + "value": "" + }, + "8b77098df0c3": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "8f8296303a77": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "ac9996319e05": { + "name": "actionItem", + "value": { + "$rpc": "null" + } + }, + "adec34c2065c": { + "creating": { + "$rpc": "null" + }, + "error": "", + "settings": {} + }, + "b759ab27e4dd": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "baa74a0ec378": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"orc-1\",\"displayName\":\"ORC-1 Recorded issue\",\"displayNameKind\":\"generated\",\"linkedLinearIssue\":\"ORC-1\",\"setupDecision\":\"inherit\",\"activate\":true,\"startupDraft\":\"https://linear.app/orca/issue/ORC-1\",\"createdWithAgent\":\"claude\"}}" + }, + "c9cb32059b8d": { + "name": "creatingKey", + "value": { + "$rpc": "null" + } + }, + "d27ce798af34": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "d5df3f6b123a": { + "creating": { + "$rpc": "null" + }, + "error": "Selected agent is disabled. Choose an enabled agent before creating.", + "settings": { + "disabledTuiAgents": ["claude"] + } + }, + "dae7907f03cc": { + "name": "runtimeTaskSettings", + "value": {} + }, + "e0cf1af55a54": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "e1bd8b4a5d70": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "ea709e13f0f0": { + "name": "workspaceAgentOverridden", + "value": false + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "f84a8688af61": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + } + }, + "recording": { + "scenario": "matrix-settings.task-workspace-create-settings.get-1", + "checkpoints": [ + { + "id": "settings-task-workspace-create-linear.prelude:settings-pending", + "observation": { + "sender": ["090c88478661"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "9270aeb7d9c6" + }, + "state": "7abdfe20af50", + "effects": ["730f92993963", "82cd71d524c8"] + } + }, + { + "id": "settings-task-workspace-create-linear.prelude:cleanup", + "observation": { + "sender": ["f84a8688af61"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "7abdfe20af50", + "effects": [ + "730f92993963", + "82cd71d524c8", + "3f453dd79b03", + "ea709e13f0f0", + "3405a06dce84", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.normal:created", + "observation": { + "sender": ["2473f12c7cdd", "0f72e7ee78c9"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "33e3b949d4c5", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "7ee99993a895", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.result-absent:created", + "observation": { + "sender": ["e0cf1af55a54"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "d5df3f6b123a", + "effects": [ + "730f92993963", + "82cd71d524c8", + "3f453dd79b03", + "ea709e13f0f0", + "3405a06dce84", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.result-null:created", + "observation": { + "sender": ["e1bd8b4a5d70"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "d5df3f6b123a", + "effects": [ + "730f92993963", + "82cd71d524c8", + "3f453dd79b03", + "ea709e13f0f0", + "3405a06dce84", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.inner-ok-missing:created", + "observation": { + "sender": ["0fc3e204e7ba", "0f72e7ee78c9"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "adec34c2065c", + "effects": [ + "730f92993963", + "82cd71d524c8", + "dae7907f03cc", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "7ee99993a895", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.inner-false-string-error:created", + "observation": { + "sender": ["d27ce798af34", "0f72e7ee78c9"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "adec34c2065c", + "effects": [ + "730f92993963", + "82cd71d524c8", + "dae7907f03cc", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "7ee99993a895", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.inner-false-object-error:created", + "observation": { + "sender": ["127ad2bdc042", "0f72e7ee78c9"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "adec34c2065c", + "effects": [ + "730f92993963", + "82cd71d524c8", + "dae7907f03cc", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "7ee99993a895", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.outer-refused:created", + "observation": { + "sender": ["8f8296303a77"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "d5df3f6b123a", + "effects": [ + "730f92993963", + "82cd71d524c8", + "3f453dd79b03", + "ea709e13f0f0", + "3405a06dce84", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.outer-refused-no-message:created", + "observation": { + "sender": ["6a98511b6371"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "d5df3f6b123a", + "effects": [ + "730f92993963", + "82cd71d524c8", + "3f453dd79b03", + "ea709e13f0f0", + "3405a06dce84", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.method-not-found:created", + "observation": { + "sender": ["b759ab27e4dd"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "d5df3f6b123a", + "effects": [ + "730f92993963", + "82cd71d524c8", + "3f453dd79b03", + "ea709e13f0f0", + "3405a06dce84", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.transport-rejection:created", + "observation": { + "sender": ["8b77098df0c3"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "d5df3f6b123a", + "effects": [ + "730f92993963", + "82cd71d524c8", + "3f453dd79b03", + "ea709e13f0f0", + "3405a06dce84", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.transport-rejection-no-message:created", + "observation": { + "sender": ["2b3aa0da0852"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "d5df3f6b123a", + "effects": [ + "730f92993963", + "82cd71d524c8", + "3f453dd79b03", + "ea709e13f0f0", + "3405a06dce84", + "c9cb32059b8d" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json new file mode 100644 index 00000000000..1ccd90e4c6e --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json @@ -0,0 +1,1010 @@ +{ + "operation": "settings.task-workspace-create", + "family": "settings.task-workspace-create", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "1d7713cf4c23d053105c2abb02340d81d5eb689f4311a0984932d8ebd031b4ce", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "067cef118d9f": { + "name": "runtimeTaskSettings", + "value": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "090c88478661": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "0ca3bb7ac195": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "0f72e7ee78c9": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "worktree": { + "displayName": "ORC-1 Recorded issue", + "id": "wt-1" + } + } + } + } + }, + "12b9d0436b8a": { + "name": "error", + "value": "Cannot read properties of undefined (reading 'displayName')" + }, + "180125f5d1a6": { + "name": "workspaceCreateDraft", + "value": { + "$rpc": "null" + } + }, + "186f44bc465a": { + "name": "error", + "value": "Unknown method" + }, + "1bb065c2a768": { + "creating": { + "$rpc": "null" + }, + "error": "Unknown method", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "2473f12c7cdd": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + } + } + } + }, + "2e80de97dd3b": { + "name": "error", + "value": "Cannot read properties of null (reading 'worktree')" + }, + "2f13b6f74cc6": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "2fac0da15fae": { + "creating": { + "$rpc": "null" + }, + "error": "transport failure", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "31738898988e": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "33e3b949d4c5": { + "creating": { + "$rpc": "null" + }, + "error": "", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "37345621a939": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "5b8e61be1638": { + "name": "error", + "value": "Cannot read properties of undefined (reading 'worktree')" + }, + "67b44e804cc9": { + "creating": { + "$rpc": "null" + }, + "error": "Cannot read properties of null (reading 'worktree')", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "6eb4e79ad99a": { + "name": "setupPrompt", + "value": { + "$rpc": "null" + } + }, + "730f92993963": { + "name": "creatingKey", + "value": "linear:1" + }, + "7abdfe20af50": { + "creating": "linear:1", + "error": "", + "settings": { + "disabledTuiAgents": ["claude"] + } + }, + "7b27297e7f2d": { + "creating": { + "$rpc": "null" + }, + "error": "outer refused", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "7ddcb1852b39": { + "name": "settings.get#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "7ee99993a895": { + "name": "navigation", + "value": "/h/host-1/session/wt-1?name=ORC-1+Recorded+issue&created=1" + }, + "82cd71d524c8": { + "name": "error", + "value": "" + }, + "841ba02855c9": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "89456eae5a16": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "945ea389c1ef": { + "name": "error", + "value": "transport failure" + }, + "97348f3fe285": { + "creating": { + "$rpc": "null" + }, + "error": "Cannot read properties of undefined (reading 'worktree')", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "97dc8fc98386": { + "creating": { + "$rpc": "null" + }, + "error": "Cannot read properties of undefined (reading 'displayName')", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "9f82f10075a3": { + "name": "error", + "value": "Connection closed" + }, + "ac9996319e05": { + "name": "actionItem", + "value": { + "$rpc": "null" + } + }, + "adfc4e9a82be": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-2", + "ok": false + } + } + }, + "ba65a7abe43b": { + "name": "error", + "value": "outer refused" + }, + "baa74a0ec378": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"orc-1\",\"displayName\":\"ORC-1 Recorded issue\",\"displayNameKind\":\"generated\",\"linkedLinearIssue\":\"ORC-1\",\"setupDecision\":\"inherit\",\"activate\":true,\"startupDraft\":\"https://linear.app/orca/issue/ORC-1\",\"createdWithAgent\":\"claude\"}}" + }, + "c7d9517809c8": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-2", + "ok": false + } + } + }, + "c9cb32059b8d": { + "name": "creatingKey", + "value": { + "$rpc": "null" + } + }, + "eb44ca9ac41f": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "eecc0c1b6490": { + "creating": "linear:1", + "error": "", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "f73b6faeedba": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "ff28e2c78e1b": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + } + }, + "recording": { + "scenario": "matrix-settings.task-workspace-create-worktree.create-1", + "checkpoints": [ + { + "id": "settings-task-workspace-create-linear.prelude:settings-pending", + "observation": { + "sender": ["090c88478661"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "9270aeb7d9c6" + }, + "state": "7abdfe20af50", + "effects": ["730f92993963", "82cd71d524c8"] + } + }, + { + "id": "settings-task-workspace-create-linear.prelude:cleanup", + "observation": { + "sender": ["2473f12c7cdd", "eb44ca9ac41f"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "eecc0c1b6490", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "9f82f10075a3", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.normal:created", + "observation": { + "sender": ["2473f12c7cdd", "0f72e7ee78c9"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "33e3b949d4c5", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "7ee99993a895", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.result-absent:created", + "observation": { + "sender": ["2473f12c7cdd", "841ba02855c9"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "97348f3fe285", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "5b8e61be1638", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.result-null:created", + "observation": { + "sender": ["2473f12c7cdd", "0ca3bb7ac195"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "67b44e804cc9", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "2e80de97dd3b", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.inner-ok-missing:created", + "observation": { + "sender": ["2473f12c7cdd", "ff28e2c78e1b"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "97dc8fc98386", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "12b9d0436b8a", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.inner-false-string-error:created", + "observation": { + "sender": ["2473f12c7cdd", "89456eae5a16"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "97dc8fc98386", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "12b9d0436b8a", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.inner-false-object-error:created", + "observation": { + "sender": ["2473f12c7cdd", "f73b6faeedba"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "97dc8fc98386", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "12b9d0436b8a", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.outer-refused:created", + "observation": { + "sender": ["2473f12c7cdd", "c7d9517809c8"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "7b27297e7f2d", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "ba65a7abe43b", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.outer-refused-no-message:created", + "observation": { + "sender": ["2473f12c7cdd", "2f13b6f74cc6"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "33e3b949d4c5", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "82cd71d524c8", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.method-not-found:created", + "observation": { + "sender": ["2473f12c7cdd", "adfc4e9a82be"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "1bb065c2a768", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "186f44bc465a", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.transport-rejection:created", + "observation": { + "sender": ["2473f12c7cdd", "31738898988e"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "2fac0da15fae", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "945ea389c1ef", + "c9cb32059b8d" + ] + } + }, + { + "id": "settings-task-workspace-create-linear.transport-rejection-no-message:created", + "observation": { + "sender": ["2473f12c7cdd", "37345621a939"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "33e3b949d4c5", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "82cd71d524c8", + "c9cb32059b8d" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json index 1a43cd52057..acd992a7643 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json @@ -3,9 +3,9 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "994ea8b4ddb05774a8c2d5902bb68bf5e8f25399a787262b8f23f458f2790698", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json index b71bb648aae..362a218cdad 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json @@ -3,9 +3,9 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "30b3f8d79589e9fb3d7ef804233554fa231f68ab88e5130ddfa78e79221e3c78", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json index cb92182e684..45564a167e7 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json @@ -3,9 +3,9 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a5366cbd31d899feeb7e1901edd0c78191c2c8c8179ad5d5b24b7ca22bd538f8", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json index d73c6ab0aa7..e26dc16d6ad 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json @@ -3,9 +3,9 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "797ea410af6536410335ebe93b8bc354cd633cf980eb95efbb10bc46f5516cb7", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json index d9457d90a8f..94da15df846 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json @@ -3,9 +3,9 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "3cd29e7b6a1cdfd99796a58cf6ad6f9aa3dbac75dd6e989ea99ba6027c210028", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json index 34486618f06..170d452ac51 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json @@ -3,9 +3,9 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a89bdf93df71a958810aba72c80e42f663644781a29e934898e2ddf86c5dd5d5", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json new file mode 100644 index 00000000000..e408bae4119 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json @@ -0,0 +1,1081 @@ +{ + "operation": "tasks.paste-lookup", + "family": "tasks.paste-lookup", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "749f877ac0c08860f74fc56e34b07f51960dda5bd1fdcf9df847b5200bf67779", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "09b235c17bb0": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "0e9d6525a582": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "11ab96fde6c9": { + "name": "gitlab.workItemByPath#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemByPath\",\"params\":{\"repo\":\"id:repo-1\",\"host\":\"gitlab.com\",\"path\":\"group/project\",\"iid\":7,\"type\":\"issue\"}}" + }, + "135faf86ace7": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "error": "inner refused", + "ok": false + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "1c3567f57943": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true + } + } + }, + "2113a0cc7708": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "285ceb964a96": { + "name": "github.repoSlug#2", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-2" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "398515139d34": { + "name": "github.repoSlug#2", + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"github.repoSlug\",\"params\":{\"repo\":\"id:repo-2\"}}" + }, + "46e234697d93": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of undefined (reading 'toLowerCase')", + "isRpcDeliveryUnknown": false + } + }, + "4a3429622287": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "4eec4620374a": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "error": { + "message": "inner refused" + }, + "ok": false + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "5248ebd8f08a": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "65342779da15": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + } + }, + "6662fbe6a28e": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-4", + "ok": false + } + } + }, + "731507dd2e23": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + } + }, + "7445a582a9c8": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "owner": "owner", + "repo": "repo" + } + } + } + }, + "8f410b944069": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-4", + "ok": false + } + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "9e6675f5d017": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "a091594f56e6": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "$rpc": "null" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "a3d7eef0da8a": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-4", + "ok": false + } + } + }, + "a45a7dd68af6": { + "name": "github.repoSlug#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"github.repoSlug\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "aaad292bbd1b": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "aaf80675fc49": { + "name": "github.workItem#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.workItem\",\"params\":{\"repo\":\"id:repo-1\",\"number\":12}}" + }, + "b303200fec39": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "bd533f6b0b40": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "cded841b4a1b": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "d48fa181d583": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "error": "refused" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "e1f537905a65": { + "name": "github.workItemByOwnerRepo#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.workItemByOwnerRepo\",\"params\":{\"repo\":\"id:repo-1\",\"owner\":\"owner\",\"ownerRepo\":\"repo\",\"number\":12,\"type\":\"issue\"}}" + }, + "e29333b1693f": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "iid": 7, + "title": "seven" + } + } + } + }, + "e2af62b90b0b": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "$rpc": "null" + } + ], + [ + "repo-2", + { + "$rpc": "null" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "$rpc": "null" + } + }, + "e970eb27f5ca": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "ee20a1dc39e7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "null" + } + }, + "f0486ebd441c": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "f9ea1f747023": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.paste-lookup-github.reposlug-1", + "checkpoints": [ + { + "id": "tw-paste-lookup-resolved.prelude:by-number", + "observation": { + "sender": ["65342779da15"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "731507dd2e23" + }, + "state": "09b235c17bb0", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.prelude:by-slug", + "observation": { + "sender": ["65342779da15", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23" + }, + "state": "e970eb27f5ca", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.prelude:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "4a3429622287", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.normal:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "2113a0cc7708", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-absent:repo-slug-matched", + "observation": { + "sender": [ + "65342779da15", + "f9ea1f747023", + "e29333b1693f", + "1c3567f57943", + "285ceb964a96" + ], + "payloads": [ + "aaf80675fc49", + "e1f537905a65", + "11ab96fde6c9", + "a45a7dd68af6", + "398515139d34" + ], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "9270aeb7d9c6" + }, + "state": "a091594f56e6", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-null:repo-slug-matched", + "observation": { + "sender": [ + "65342779da15", + "f9ea1f747023", + "e29333b1693f", + "aaad292bbd1b", + "285ceb964a96" + ], + "payloads": [ + "aaf80675fc49", + "e1f537905a65", + "11ab96fde6c9", + "a45a7dd68af6", + "398515139d34" + ], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "9270aeb7d9c6" + }, + "state": "a091594f56e6", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-ok-missing:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f", "f0486ebd441c"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "46e234697d93" + }, + "state": "d48fa181d583", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-string-error:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f", "9e6675f5d017"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "46e234697d93" + }, + "state": "135faf86ace7", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-object-error:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f", "cded841b4a1b"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "46e234697d93" + }, + "state": "4eec4620374a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused:repo-slug-matched", + "observation": { + "sender": [ + "65342779da15", + "f9ea1f747023", + "e29333b1693f", + "a3d7eef0da8a", + "285ceb964a96" + ], + "payloads": [ + "aaf80675fc49", + "e1f537905a65", + "11ab96fde6c9", + "a45a7dd68af6", + "398515139d34" + ], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "9270aeb7d9c6" + }, + "state": "a091594f56e6", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused-no-message:repo-slug-matched", + "observation": { + "sender": [ + "65342779da15", + "f9ea1f747023", + "e29333b1693f", + "8f410b944069", + "285ceb964a96" + ], + "payloads": [ + "aaf80675fc49", + "e1f537905a65", + "11ab96fde6c9", + "a45a7dd68af6", + "398515139d34" + ], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "9270aeb7d9c6" + }, + "state": "a091594f56e6", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.method-not-found:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f", "6662fbe6a28e"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "ee20a1dc39e7" + }, + "state": "e2af62b90b0b", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection:repo-slug-matched", + "observation": { + "sender": [ + "65342779da15", + "f9ea1f747023", + "e29333b1693f", + "5248ebd8f08a", + "285ceb964a96" + ], + "payloads": [ + "aaf80675fc49", + "e1f537905a65", + "11ab96fde6c9", + "a45a7dd68af6", + "398515139d34" + ], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "9270aeb7d9c6" + }, + "state": "a091594f56e6", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection-no-message:repo-slug-matched", + "observation": { + "sender": [ + "65342779da15", + "f9ea1f747023", + "e29333b1693f", + "b303200fec39", + "285ceb964a96" + ], + "payloads": [ + "aaf80675fc49", + "e1f537905a65", + "11ab96fde6c9", + "a45a7dd68af6", + "398515139d34" + ], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "9270aeb7d9c6" + }, + "state": "a091594f56e6", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json new file mode 100644 index 00000000000..4993b35565a --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json @@ -0,0 +1,1645 @@ +{ + "operation": "tasks.paste-lookup", + "family": "tasks.paste-lookup", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "2a11156b7b6d3cf0773c8dc02a72e126bc187dcf62ad7d1bf5f30d7b27192b03", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "06c63d693b0e": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "09b235c17bb0": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "0e9d6525a582": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "11ab96fde6c9": { + "name": "gitlab.workItemByPath#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemByPath\",\"params\":{\"repo\":\"id:repo-1\",\"host\":\"gitlab.com\",\"path\":\"group/project\",\"iid\":7,\"type\":\"issue\"}}" + }, + "19bc74accf11": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "1e7d56be018c": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "2113a0cc7708": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "21981303c684": { + "by-number": { + "$rpc": "null" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "38e35cd6299a": { + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "3bae2da7492a": { + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "3c03bbd195d8": { + "by-number": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "3e4f8a2833ba": { + "by-number": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "4267f9cc3919": { + "by-number": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "46daeacd502c": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "refused", + "repoId": "repo-1" + } + }, + "4a3429622287": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "5827c760c69a": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "5b601868bb59": { + "by-number": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "65342779da15": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + } + }, + "731507dd2e23": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + } + }, + "73fe68f6a6fc": { + "by-number": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + }, + "cache": [] + }, + "7445a582a9c8": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "owner": "owner", + "repo": "repo" + } + } + } + }, + "870d10fe8de9": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "894b40a0b814": { + "by-number": { + "$rpc": "null" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "896610e0c4e7": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "8aa944d2f7a1": { + "cache": [] + }, + "8f8ff0f7d554": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "90de73e52a3d": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "9dcdd903c10f": { + "by-number": { + "error": "refused", + "repoId": "repo-1" + }, + "cache": [] + }, + "9e9f15f7df58": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + } + }, + "a45a7dd68af6": { + "name": "github.repoSlug#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"github.repoSlug\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "a7f4472cdb70": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "aaf80675fc49": { + "name": "github.workItem#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.workItem\",\"params\":{\"repo\":\"id:repo-1\",\"number\":12}}" + }, + "ad658847a638": { + "by-number": { + "error": "refused", + "repoId": "repo-1" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "b19c59c5ee88": { + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "b70f6ec811e0": { + "by-number": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "bd533f6b0b40": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "c392cc9aa63a": { + "by-number": { + "$rpc": "null" + }, + "cache": [] + }, + "c57df09a398c": { + "by-number": { + "error": "refused", + "repoId": "repo-1" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "c6646b64fc57": { + "by-number": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + }, + "cache": [] + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "c9a1abec42e3": { + "by-number": { + "$rpc": "null" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "d0150efe4124": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "d65cceb204a7": { + "by-number": { + "error": "refused", + "repoId": "repo-1" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "de772915aa03": { + "by-number": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "e1f537905a65": { + "name": "github.workItemByOwnerRepo#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.workItemByOwnerRepo\",\"params\":{\"repo\":\"id:repo-1\",\"owner\":\"owner\",\"ownerRepo\":\"repo\",\"number\":12,\"type\":\"issue\"}}" + }, + "e29333b1693f": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "iid": 7, + "title": "seven" + } + } + } + }, + "e970eb27f5ca": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "ee20a1dc39e7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "null" + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "f9ea1f747023": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + } + }, + "fb69e80392e8": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.paste-lookup-github.workitem-1", + "checkpoints": [ + { + "id": "tw-paste-lookup-resolved.normal:by-number", + "observation": { + "sender": ["65342779da15"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "731507dd2e23" + }, + "state": "09b235c17bb0", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.normal:by-slug", + "observation": { + "sender": ["65342779da15", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23" + }, + "state": "e970eb27f5ca", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.normal:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "4a3429622287", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.normal:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "2113a0cc7708", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-absent:by-number", + "observation": { + "sender": ["fb69e80392e8"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "ee20a1dc39e7" + }, + "state": "c392cc9aa63a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-absent:by-slug", + "observation": { + "sender": ["fb69e80392e8", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "ee20a1dc39e7", + "by-slug": "731507dd2e23" + }, + "state": "21981303c684", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-absent:gitlab-path", + "observation": { + "sender": ["fb69e80392e8", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "ee20a1dc39e7", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "c9a1abec42e3", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-absent:repo-slug-matched", + "observation": { + "sender": ["fb69e80392e8", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "ee20a1dc39e7", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "894b40a0b814", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-null:by-number", + "observation": { + "sender": ["5827c760c69a"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "ee20a1dc39e7" + }, + "state": "c392cc9aa63a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-null:by-slug", + "observation": { + "sender": ["5827c760c69a", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "ee20a1dc39e7", + "by-slug": "731507dd2e23" + }, + "state": "21981303c684", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-null:gitlab-path", + "observation": { + "sender": ["5827c760c69a", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "ee20a1dc39e7", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "c9a1abec42e3", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-null:repo-slug-matched", + "observation": { + "sender": ["5827c760c69a", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "ee20a1dc39e7", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "894b40a0b814", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-ok-missing:by-number", + "observation": { + "sender": ["19bc74accf11"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "46daeacd502c" + }, + "state": "9dcdd903c10f", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-ok-missing:by-slug", + "observation": { + "sender": ["19bc74accf11", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "46daeacd502c", + "by-slug": "731507dd2e23" + }, + "state": "d65cceb204a7", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-ok-missing:gitlab-path", + "observation": { + "sender": ["19bc74accf11", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "46daeacd502c", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "c57df09a398c", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-ok-missing:repo-slug-matched", + "observation": { + "sender": ["19bc74accf11", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "46daeacd502c", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "ad658847a638", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-string-error:by-number", + "observation": { + "sender": ["d0150efe4124"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "9e9f15f7df58" + }, + "state": "73fe68f6a6fc", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-string-error:by-slug", + "observation": { + "sender": ["d0150efe4124", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "9e9f15f7df58", + "by-slug": "731507dd2e23" + }, + "state": "b70f6ec811e0", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-string-error:gitlab-path", + "observation": { + "sender": ["d0150efe4124", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "9e9f15f7df58", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "3e4f8a2833ba", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-string-error:repo-slug-matched", + "observation": { + "sender": ["d0150efe4124", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "9e9f15f7df58", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "3c03bbd195d8", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-object-error:by-number", + "observation": { + "sender": ["896610e0c4e7"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "a7f4472cdb70" + }, + "state": "c6646b64fc57", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-object-error:by-slug", + "observation": { + "sender": ["896610e0c4e7", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "a7f4472cdb70", + "by-slug": "731507dd2e23" + }, + "state": "4267f9cc3919", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-object-error:gitlab-path", + "observation": { + "sender": ["896610e0c4e7", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "a7f4472cdb70", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "5b601868bb59", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-object-error:repo-slug-matched", + "observation": { + "sender": ["896610e0c4e7", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "a7f4472cdb70", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "de772915aa03", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused:by-number", + "observation": { + "sender": ["90de73e52a3d"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "32a7c0ae7918" + }, + "state": "8aa944d2f7a1", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused:by-slug", + "observation": { + "sender": ["90de73e52a3d", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "32a7c0ae7918", + "by-slug": "731507dd2e23" + }, + "state": "b19c59c5ee88", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused:gitlab-path", + "observation": { + "sender": ["90de73e52a3d", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "32a7c0ae7918", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "38e35cd6299a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused:repo-slug-matched", + "observation": { + "sender": ["90de73e52a3d", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "32a7c0ae7918", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "3bae2da7492a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused-no-message:by-number", + "observation": { + "sender": ["870d10fe8de9"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "f3b516f62081" + }, + "state": "8aa944d2f7a1", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused-no-message:by-slug", + "observation": { + "sender": ["870d10fe8de9", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "f3b516f62081", + "by-slug": "731507dd2e23" + }, + "state": "b19c59c5ee88", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused-no-message:gitlab-path", + "observation": { + "sender": ["870d10fe8de9", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "f3b516f62081", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "38e35cd6299a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused-no-message:repo-slug-matched", + "observation": { + "sender": ["870d10fe8de9", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "f3b516f62081", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "3bae2da7492a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.method-not-found:by-number", + "observation": { + "sender": ["06c63d693b0e"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "b948e8307e81" + }, + "state": "8aa944d2f7a1", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.method-not-found:by-slug", + "observation": { + "sender": ["06c63d693b0e", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "b948e8307e81", + "by-slug": "731507dd2e23" + }, + "state": "b19c59c5ee88", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.method-not-found:gitlab-path", + "observation": { + "sender": ["06c63d693b0e", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "b948e8307e81", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "38e35cd6299a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.method-not-found:repo-slug-matched", + "observation": { + "sender": ["06c63d693b0e", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "b948e8307e81", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "3bae2da7492a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection:by-number", + "observation": { + "sender": ["1e7d56be018c"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "a947768bc0ed" + }, + "state": "8aa944d2f7a1", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection:by-slug", + "observation": { + "sender": ["1e7d56be018c", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "a947768bc0ed", + "by-slug": "731507dd2e23" + }, + "state": "b19c59c5ee88", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection:gitlab-path", + "observation": { + "sender": ["1e7d56be018c", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "a947768bc0ed", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "38e35cd6299a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection:repo-slug-matched", + "observation": { + "sender": ["1e7d56be018c", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "a947768bc0ed", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "3bae2da7492a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection-no-message:by-number", + "observation": { + "sender": ["8f8ff0f7d554"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "c7584e82c72f" + }, + "state": "8aa944d2f7a1", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection-no-message:by-slug", + "observation": { + "sender": ["8f8ff0f7d554", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "c7584e82c72f", + "by-slug": "731507dd2e23" + }, + "state": "b19c59c5ee88", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection-no-message:gitlab-path", + "observation": { + "sender": ["8f8ff0f7d554", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "c7584e82c72f", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "38e35cd6299a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection-no-message:repo-slug-matched", + "observation": { + "sender": ["8f8ff0f7d554", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "c7584e82c72f", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "3bae2da7492a", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json new file mode 100644 index 00000000000..f425da8f3fa --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json @@ -0,0 +1,1513 @@ +{ + "operation": "tasks.paste-lookup", + "family": "tasks.paste-lookup", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "4c2560ac236a1cc1ef239b7c97a0436e115b19a6ad3ddd96b3b77970aa631ae3", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "043383809888": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "09b235c17bb0": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "0e35851dfc19": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-2", + "ok": false + } + } + }, + "0e9d6525a582": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "11ab96fde6c9": { + "name": "gitlab.workItemByPath#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemByPath\",\"params\":{\"repo\":\"id:repo-1\",\"host\":\"gitlab.com\",\"path\":\"group/project\",\"iid\":7,\"type\":\"issue\"}}" + }, + "1930e5b10aa4": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "2113a0cc7708": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "27ddfa6b2efa": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + }, + "cache": [] + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "34d2c8648702": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-2", + "ok": false + } + } + }, + "36efe7e0f4f2": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "error": "refused", + "repoId": "repo-1" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "3b70826f7fe6": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "3c4b264bef1c": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "$rpc": "null" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "46daeacd502c": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "refused", + "repoId": "repo-1" + } + }, + "49054e5c9fe8": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "4a3429622287": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "518d26b50905": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "65342779da15": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + } + }, + "6ec2e8b6f903": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "$rpc": "null" + }, + "cache": [] + }, + "731507dd2e23": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + } + }, + "7445a582a9c8": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "owner": "owner", + "repo": "repo" + } + } + } + }, + "825e80908bc2": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "873ee3388e70": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "90adb9377343": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "9221b9a7a4b0": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "92bb68fe4007": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "9e9f15f7df58": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + } + }, + "9fe9e3dcad5b": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "a45a7dd68af6": { + "name": "github.repoSlug#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"github.repoSlug\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "a7f4472cdb70": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "aaf80675fc49": { + "name": "github.workItem#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.workItem\",\"params\":{\"repo\":\"id:repo-1\",\"number\":12}}" + }, + "ab24157c895e": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "error": "refused", + "repoId": "repo-1" + }, + "cache": [] + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "bd533f6b0b40": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "bf5c299a7c14": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "$rpc": "null" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "c2fb4513d62f": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "error": "refused", + "repoId": "repo-1" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "c4a429577522": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "d58cca0b1bf7": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "dffa1578b2eb": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "e1f537905a65": { + "name": "github.workItemByOwnerRepo#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.workItemByOwnerRepo\",\"params\":{\"repo\":\"id:repo-1\",\"owner\":\"owner\",\"ownerRepo\":\"repo\",\"number\":12,\"type\":\"issue\"}}" + }, + "e29333b1693f": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "iid": 7, + "title": "seven" + } + } + } + }, + "e970eb27f5ca": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "ebc4e477d2ce": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + }, + "cache": [] + }, + "ee20a1dc39e7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "null" + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "f9ea1f747023": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.paste-lookup-github.workitembyownerrepo-1", + "checkpoints": [ + { + "id": "tw-paste-lookup-resolved.prelude:by-number", + "observation": { + "sender": ["65342779da15"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "731507dd2e23" + }, + "state": "09b235c17bb0", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.normal:by-slug", + "observation": { + "sender": ["65342779da15", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23" + }, + "state": "e970eb27f5ca", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.normal:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "4a3429622287", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.normal:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "2113a0cc7708", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-absent:by-slug", + "observation": { + "sender": ["65342779da15", "3b70826f7fe6"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "ee20a1dc39e7" + }, + "state": "6ec2e8b6f903", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-absent:gitlab-path", + "observation": { + "sender": ["65342779da15", "3b70826f7fe6", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "ee20a1dc39e7", + "gitlab-path": "bd533f6b0b40" + }, + "state": "3c4b264bef1c", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-absent:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "3b70826f7fe6", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "ee20a1dc39e7", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "bf5c299a7c14", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-null:by-slug", + "observation": { + "sender": ["65342779da15", "d58cca0b1bf7"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "ee20a1dc39e7" + }, + "state": "6ec2e8b6f903", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-null:gitlab-path", + "observation": { + "sender": ["65342779da15", "d58cca0b1bf7", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "ee20a1dc39e7", + "gitlab-path": "bd533f6b0b40" + }, + "state": "3c4b264bef1c", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-null:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "d58cca0b1bf7", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "ee20a1dc39e7", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "bf5c299a7c14", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-ok-missing:by-slug", + "observation": { + "sender": ["65342779da15", "c4a429577522"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "46daeacd502c" + }, + "state": "ab24157c895e", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-ok-missing:gitlab-path", + "observation": { + "sender": ["65342779da15", "c4a429577522", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "46daeacd502c", + "gitlab-path": "bd533f6b0b40" + }, + "state": "c2fb4513d62f", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-ok-missing:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "c4a429577522", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "46daeacd502c", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "36efe7e0f4f2", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-string-error:by-slug", + "observation": { + "sender": ["65342779da15", "90adb9377343"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "9e9f15f7df58" + }, + "state": "ebc4e477d2ce", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-string-error:gitlab-path", + "observation": { + "sender": ["65342779da15", "90adb9377343", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "9e9f15f7df58", + "gitlab-path": "bd533f6b0b40" + }, + "state": "92bb68fe4007", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-string-error:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "90adb9377343", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "9e9f15f7df58", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "873ee3388e70", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-object-error:by-slug", + "observation": { + "sender": ["65342779da15", "9221b9a7a4b0"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "a7f4472cdb70" + }, + "state": "27ddfa6b2efa", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-object-error:gitlab-path", + "observation": { + "sender": ["65342779da15", "9221b9a7a4b0", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "a7f4472cdb70", + "gitlab-path": "bd533f6b0b40" + }, + "state": "dffa1578b2eb", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-object-error:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "9221b9a7a4b0", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "a7f4472cdb70", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "043383809888", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused:by-slug", + "observation": { + "sender": ["65342779da15", "34d2c8648702"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "32a7c0ae7918" + }, + "state": "09b235c17bb0", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused:gitlab-path", + "observation": { + "sender": ["65342779da15", "34d2c8648702", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "32a7c0ae7918", + "gitlab-path": "bd533f6b0b40" + }, + "state": "49054e5c9fe8", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "34d2c8648702", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "32a7c0ae7918", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "1930e5b10aa4", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused-no-message:by-slug", + "observation": { + "sender": ["65342779da15", "9fe9e3dcad5b"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "f3b516f62081" + }, + "state": "09b235c17bb0", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused-no-message:gitlab-path", + "observation": { + "sender": ["65342779da15", "9fe9e3dcad5b", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "f3b516f62081", + "gitlab-path": "bd533f6b0b40" + }, + "state": "49054e5c9fe8", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused-no-message:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "9fe9e3dcad5b", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "f3b516f62081", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "1930e5b10aa4", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.method-not-found:by-slug", + "observation": { + "sender": ["65342779da15", "0e35851dfc19"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "b948e8307e81" + }, + "state": "09b235c17bb0", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.method-not-found:gitlab-path", + "observation": { + "sender": ["65342779da15", "0e35851dfc19", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "b948e8307e81", + "gitlab-path": "bd533f6b0b40" + }, + "state": "49054e5c9fe8", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.method-not-found:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "0e35851dfc19", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "b948e8307e81", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "1930e5b10aa4", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection:by-slug", + "observation": { + "sender": ["65342779da15", "518d26b50905"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "a947768bc0ed" + }, + "state": "09b235c17bb0", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection:gitlab-path", + "observation": { + "sender": ["65342779da15", "518d26b50905", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "a947768bc0ed", + "gitlab-path": "bd533f6b0b40" + }, + "state": "49054e5c9fe8", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "518d26b50905", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "a947768bc0ed", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "1930e5b10aa4", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection-no-message:by-slug", + "observation": { + "sender": ["65342779da15", "825e80908bc2"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "c7584e82c72f" + }, + "state": "09b235c17bb0", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection-no-message:gitlab-path", + "observation": { + "sender": ["65342779da15", "825e80908bc2", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "c7584e82c72f", + "gitlab-path": "bd533f6b0b40" + }, + "state": "49054e5c9fe8", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection-no-message:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "825e80908bc2", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "c7584e82c72f", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "1930e5b10aa4", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json new file mode 100644 index 00000000000..5214024b62a --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json @@ -0,0 +1,1319 @@ +{ + "operation": "tasks.paste-lookup", + "family": "tasks.paste-lookup", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "ab5511fa34181dc9de590df2fe57a0d061a261e7b204a6ef830c03bc53923d65", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "06c1311be9ff": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "$rpc": "null" + } + }, + "09b235c17bb0": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "0b3a62a33eb9": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "error": "refused", + "repoId": "repo-1" + } + }, + "0c4bd9fe5448": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "0e9d6525a582": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "11ab96fde6c9": { + "name": "gitlab.workItemByPath#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemByPath\",\"params\":{\"repo\":\"id:repo-1\",\"host\":\"gitlab.com\",\"path\":\"group/project\",\"iid\":7,\"type\":\"issue\"}}" + }, + "2113a0cc7708": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "22a0e7139433": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "237ac027130f": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + } + }, + "2c926252e701": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "46daeacd502c": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "refused", + "repoId": "repo-1" + } + }, + "4a3429622287": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "4ef10450d3fc": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-3", + "ok": false + } + } + }, + "5f9434462f8a": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true + } + } + }, + "65342779da15": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + } + }, + "6945ba429114": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "error": "refused", + "repoId": "repo-1" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "70f924b6a03a": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "731507dd2e23": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + } + }, + "737574c61e8d": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-3", + "ok": false + } + } + }, + "7445a582a9c8": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "owner": "owner", + "repo": "repo" + } + } + } + }, + "754e655d6508": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + } + }, + "7a02e1f7b185": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "9e9f15f7df58": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + } + }, + "a1e6f2b40722": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "$rpc": "null" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "a346acfeb887": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "a45a7dd68af6": { + "name": "github.repoSlug#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"github.repoSlug\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "a7f4472cdb70": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "message": "inner refused" + }, + "ok": false, + "repoId": "repo-1" + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "aaf80675fc49": { + "name": "github.workItem#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.workItem\",\"params\":{\"repo\":\"id:repo-1\",\"number\":12}}" + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "bd533f6b0b40": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "d1f460d3c414": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "error": "inner refused", + "ok": false, + "repoId": "repo-1" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "dfcabc236ad7": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "e1f537905a65": { + "name": "github.workItemByOwnerRepo#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.workItemByOwnerRepo\",\"params\":{\"repo\":\"id:repo-1\",\"owner\":\"owner\",\"ownerRepo\":\"repo\",\"number\":12,\"type\":\"issue\"}}" + }, + "e29333b1693f": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "iid": 7, + "title": "seven" + } + } + } + }, + "e970eb27f5ca": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "ee20a1dc39e7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "null" + } + }, + "f0766555428a": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-3", + "ok": false + } + } + }, + "f215cf4a0ca3": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "f9ea1f747023": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.paste-lookup-gitlab.workitembypath-1", + "checkpoints": [ + { + "id": "tw-paste-lookup-resolved.prelude:by-number", + "observation": { + "sender": ["65342779da15"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "731507dd2e23" + }, + "state": "09b235c17bb0", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.prelude:by-slug", + "observation": { + "sender": ["65342779da15", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23" + }, + "state": "e970eb27f5ca", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.normal:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "4a3429622287", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.normal:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "2113a0cc7708", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-absent:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "5f9434462f8a"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "ee20a1dc39e7" + }, + "state": "06c1311be9ff", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-absent:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "5f9434462f8a", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "ee20a1dc39e7", + "repo-slug": "0e9d6525a582" + }, + "state": "a1e6f2b40722", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-null:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "f215cf4a0ca3"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "ee20a1dc39e7" + }, + "state": "06c1311be9ff", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.result-null:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "f215cf4a0ca3", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "ee20a1dc39e7", + "repo-slug": "0e9d6525a582" + }, + "state": "a1e6f2b40722", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-ok-missing:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "22a0e7139433"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "46daeacd502c" + }, + "state": "0b3a62a33eb9", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-ok-missing:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "22a0e7139433", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "46daeacd502c", + "repo-slug": "0e9d6525a582" + }, + "state": "6945ba429114", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-string-error:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "0c4bd9fe5448"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "9e9f15f7df58" + }, + "state": "237ac027130f", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-string-error:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "0c4bd9fe5448", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "9e9f15f7df58", + "repo-slug": "0e9d6525a582" + }, + "state": "d1f460d3c414", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-object-error:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "dfcabc236ad7"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "a7f4472cdb70" + }, + "state": "754e655d6508", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.inner-false-object-error:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "dfcabc236ad7", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "a7f4472cdb70", + "repo-slug": "0e9d6525a582" + }, + "state": "70f924b6a03a", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "4ef10450d3fc"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "32a7c0ae7918" + }, + "state": "e970eb27f5ca", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "4ef10450d3fc", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "32a7c0ae7918", + "repo-slug": "0e9d6525a582" + }, + "state": "7a02e1f7b185", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused-no-message:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "f0766555428a"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "f3b516f62081" + }, + "state": "e970eb27f5ca", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.outer-refused-no-message:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "f0766555428a", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "f3b516f62081", + "repo-slug": "0e9d6525a582" + }, + "state": "7a02e1f7b185", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.method-not-found:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "737574c61e8d"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "b948e8307e81" + }, + "state": "e970eb27f5ca", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.method-not-found:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "737574c61e8d", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "b948e8307e81", + "repo-slug": "0e9d6525a582" + }, + "state": "7a02e1f7b185", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "2c926252e701"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "a947768bc0ed" + }, + "state": "e970eb27f5ca", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "2c926252e701", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "a947768bc0ed", + "repo-slug": "0e9d6525a582" + }, + "state": "7a02e1f7b185", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection-no-message:gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "a346acfeb887"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "c7584e82c72f" + }, + "state": "e970eb27f5ca", + "effects": [] + } + }, + { + "id": "tw-paste-lookup-resolved.transport-rejection-no-message:repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "a346acfeb887", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "c7584e82c72f", + "repo-slug": "0e9d6525a582" + }, + "state": "7a02e1f7b185", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json new file mode 100644 index 00000000000..4c21157e14c --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json @@ -0,0 +1,1885 @@ +{ + "operation": "tasks.smart-source-search", + "family": "tasks.smart-source-search", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "ea5c32c4dbb67aae1ebaf809a28104d52d189e5153d9b28285f4d8a6d78753ea", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "05e9b743fb1d": { + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "13833f2512ec": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "13ebc07aa6fe": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "155f61ed496f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of null (reading 'items')", + "isRpcDeliveryUnknown": false + } + }, + "253629bd0d20": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "25716369cd8f": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [] + }, + "25f88995b39a": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "refs": ["main", "release"] + } + } + } + }, + "26dc3b7c8299": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-3" + } + ] + }, + "2cfd107b9660": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "36290ab254a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "37c4b6aa154e": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "3828d5880c35": { + "name": "gitlab.listWorkItems#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"state\":\"opened\",\"page\":1,\"perPage\":50,\"query\":\"bug\"}}" + }, + "41d2452d4ebe": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "42f4c910f308": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "44136fa355b3": {}, + "50263a3726f9": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "518e8334f7d3": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "51a271295555": { + "github": [], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "5bce68072dc3": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "items": [ + { + "number": 1, + "title": "one" + } + ] + } + } + } + }, + "6107c951646f": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "items": [ + { + "id": "issue-1" + } + ] + } + } + } + }, + "61210ae02f8d": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "6e2d75e3bbd7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "71764b0214a9": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "80cc566cdd55": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "867ee0f6f5d8": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "96555ad1314a": { + "github": [] + }, + "a4ee5d16b4f6": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-1" + } + ] + }, + "a85937f48d97": { + "github": [], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "a92cd1dd05af": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "b015aaf3a53a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ] + }, + "b8b02a30b6b8": { + "name": "repo.searchRefs#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"repo.searchRefs\",\"params\":{\"repo\":\"id:repo-1\",\"query\":\"bug\",\"limit\":20}}" + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "c2bf4ae27078": { + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "c43e80126d82": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "ce28e5229996": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "e97e5a589476": { + "name": "linear.listIssues#1", + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"linear.listIssues\",\"params\":{\"filter\":\"assigned\",\"limit\":50}}" + }, + "ead829dd6d03": { + "name": "linear.searchIssues#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"linear.searchIssues\",\"params\":{\"query\":\"bug\",\"limit\":50,\"workspaceId\":\"linear-workspace\"}}" + }, + "ee6fe4f97b01": { + "name": "github.listWorkItems#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"limit\":36,\"query\":\"bug\"}}" + }, + "ef416ca3ea2c": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "f32ad26605d0": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "missing", + "type": "not_found" + }, + "items": [ + { + "iid": 2, + "title": "two" + } + ] + } + } + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "f51f34589c7a": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of undefined (reading 'items')", + "isRpcDeliveryUnknown": false + } + }, + "f7877799c609": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "f8f245caedb5": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "fe7f60b5d785": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": [ + { + "id": "issue-3" + } + ] + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.smart-source-search-github.listworkitems-1", + "checkpoints": [ + { + "id": "tw-smart-search-all-providers.normal:github-items", + "observation": { + "sender": ["5bce68072dc3"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "36290ab254a4" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:github-items", + "observation": { + "sender": ["f8f245caedb5"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "f51f34589c7a" + }, + "state": "44136fa355b3", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:gitlab-items", + "observation": { + "sender": ["f8f245caedb5", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "f51f34589c7a", + "gitlab": "6e2d75e3bbd7" + }, + "state": "c2bf4ae27078", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:linear-search", + "observation": { + "sender": ["f8f245caedb5", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "f51f34589c7a", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "05e9b743fb1d", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:branch-refs", + "observation": { + "sender": ["f8f245caedb5", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "f51f34589c7a", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "71764b0214a9", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:linear-assigned-listed", + "observation": { + "sender": [ + "f8f245caedb5", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "f51f34589c7a", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "13ebc07aa6fe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:github-items", + "observation": { + "sender": ["ef416ca3ea2c"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "155f61ed496f" + }, + "state": "44136fa355b3", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:gitlab-items", + "observation": { + "sender": ["ef416ca3ea2c", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "155f61ed496f", + "gitlab": "6e2d75e3bbd7" + }, + "state": "c2bf4ae27078", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:linear-search", + "observation": { + "sender": ["ef416ca3ea2c", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "155f61ed496f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "05e9b743fb1d", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:branch-refs", + "observation": { + "sender": ["ef416ca3ea2c", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "155f61ed496f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "71764b0214a9", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:linear-assigned-listed", + "observation": { + "sender": [ + "ef416ca3ea2c", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "155f61ed496f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "13ebc07aa6fe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:github-items", + "observation": { + "sender": ["f7877799c609"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "25716369cd8f" + }, + "state": "96555ad1314a", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:gitlab-items", + "observation": { + "sender": ["f7877799c609", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7" + }, + "state": "51a271295555", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:linear-search", + "observation": { + "sender": ["f7877799c609", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "a85937f48d97", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:branch-refs", + "observation": { + "sender": ["f7877799c609", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "867ee0f6f5d8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:linear-assigned-listed", + "observation": { + "sender": [ + "f7877799c609", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "518e8334f7d3", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:github-items", + "observation": { + "sender": ["13833f2512ec"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "25716369cd8f" + }, + "state": "96555ad1314a", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:gitlab-items", + "observation": { + "sender": ["13833f2512ec", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7" + }, + "state": "51a271295555", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:linear-search", + "observation": { + "sender": ["13833f2512ec", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "a85937f48d97", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:branch-refs", + "observation": { + "sender": ["13833f2512ec", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "867ee0f6f5d8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:linear-assigned-listed", + "observation": { + "sender": [ + "13833f2512ec", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "518e8334f7d3", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:github-items", + "observation": { + "sender": ["61210ae02f8d"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "25716369cd8f" + }, + "state": "96555ad1314a", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:gitlab-items", + "observation": { + "sender": ["61210ae02f8d", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7" + }, + "state": "51a271295555", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:linear-search", + "observation": { + "sender": ["61210ae02f8d", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "a85937f48d97", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:branch-refs", + "observation": { + "sender": ["61210ae02f8d", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "867ee0f6f5d8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:linear-assigned-listed", + "observation": { + "sender": [ + "61210ae02f8d", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "25716369cd8f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "518e8334f7d3", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:github-items", + "observation": { + "sender": ["50263a3726f9"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "32a7c0ae7918" + }, + "state": "44136fa355b3", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:gitlab-items", + "observation": { + "sender": ["50263a3726f9", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "32a7c0ae7918", + "gitlab": "6e2d75e3bbd7" + }, + "state": "c2bf4ae27078", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:linear-search", + "observation": { + "sender": ["50263a3726f9", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "32a7c0ae7918", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "05e9b743fb1d", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:branch-refs", + "observation": { + "sender": ["50263a3726f9", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "32a7c0ae7918", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "71764b0214a9", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:linear-assigned-listed", + "observation": { + "sender": [ + "50263a3726f9", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "32a7c0ae7918", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "13ebc07aa6fe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:github-items", + "observation": { + "sender": ["ce28e5229996"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "f3b516f62081" + }, + "state": "44136fa355b3", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:gitlab-items", + "observation": { + "sender": ["ce28e5229996", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "f3b516f62081", + "gitlab": "6e2d75e3bbd7" + }, + "state": "c2bf4ae27078", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:linear-search", + "observation": { + "sender": ["ce28e5229996", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "f3b516f62081", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "05e9b743fb1d", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:branch-refs", + "observation": { + "sender": ["ce28e5229996", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "f3b516f62081", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "71764b0214a9", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:linear-assigned-listed", + "observation": { + "sender": [ + "ce28e5229996", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "f3b516f62081", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "13ebc07aa6fe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:github-items", + "observation": { + "sender": ["80cc566cdd55"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "b948e8307e81" + }, + "state": "44136fa355b3", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:gitlab-items", + "observation": { + "sender": ["80cc566cdd55", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "b948e8307e81", + "gitlab": "6e2d75e3bbd7" + }, + "state": "c2bf4ae27078", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:linear-search", + "observation": { + "sender": ["80cc566cdd55", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "b948e8307e81", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "05e9b743fb1d", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:branch-refs", + "observation": { + "sender": ["80cc566cdd55", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "b948e8307e81", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "71764b0214a9", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:linear-assigned-listed", + "observation": { + "sender": [ + "80cc566cdd55", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "b948e8307e81", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "13ebc07aa6fe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:github-items", + "observation": { + "sender": ["37c4b6aa154e"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "a947768bc0ed" + }, + "state": "44136fa355b3", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:gitlab-items", + "observation": { + "sender": ["37c4b6aa154e", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "a947768bc0ed", + "gitlab": "6e2d75e3bbd7" + }, + "state": "c2bf4ae27078", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:linear-search", + "observation": { + "sender": ["37c4b6aa154e", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "a947768bc0ed", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "05e9b743fb1d", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:branch-refs", + "observation": { + "sender": ["37c4b6aa154e", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "a947768bc0ed", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "71764b0214a9", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:linear-assigned-listed", + "observation": { + "sender": [ + "37c4b6aa154e", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "a947768bc0ed", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "13ebc07aa6fe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:github-items", + "observation": { + "sender": ["42f4c910f308"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "c7584e82c72f" + }, + "state": "44136fa355b3", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:gitlab-items", + "observation": { + "sender": ["42f4c910f308", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "c7584e82c72f", + "gitlab": "6e2d75e3bbd7" + }, + "state": "c2bf4ae27078", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:linear-search", + "observation": { + "sender": ["42f4c910f308", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "c7584e82c72f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "05e9b743fb1d", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:branch-refs", + "observation": { + "sender": ["42f4c910f308", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "c7584e82c72f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "71764b0214a9", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:linear-assigned-listed", + "observation": { + "sender": [ + "42f4c910f308", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "c7584e82c72f", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "13ebc07aa6fe", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json new file mode 100644 index 00000000000..93b17c0671d --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json @@ -0,0 +1,1772 @@ +{ + "operation": "tasks.smart-source-search", + "family": "tasks.smart-source-search", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "541a282829d3aa5d6b66eeba06e783368f397178ff0d2bdf3e87bccdc62b690f", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "24e67c350a20": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [] + }, + "253629bd0d20": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "25716369cd8f": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [] + }, + "25f88995b39a": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "refs": ["main", "release"] + } + } + } + }, + "26dc3b7c8299": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-3" + } + ] + }, + "2cfd107b9660": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "36290ab254a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "3698dc9e21e5": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "3828d5880c35": { + "name": "gitlab.listWorkItems#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"state\":\"opened\",\"page\":1,\"perPage\":50,\"query\":\"bug\"}}" + }, + "41d2452d4ebe": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "5b5689593188": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "5bce68072dc3": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "items": [ + { + "number": 1, + "title": "one" + } + ] + } + } + } + }, + "6107c951646f": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "items": [ + { + "id": "issue-1" + } + ] + } + } + } + }, + "67ba0246dbf8": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "6c647ccd3cff": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "6e2d75e3bbd7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "6f4bd0fc6d8b": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "74bc6b65cdef": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "76fa023e535f": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "82f9caba201c": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "8eb709e28997": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "94d9f7a1e105": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "9f9af59ae576": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "a25ac3f73d46": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of null (reading 'error')", + "isRpcDeliveryUnknown": false + } + }, + "a4ee5d16b4f6": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-1" + } + ] + }, + "a92cd1dd05af": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "b015aaf3a53a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ] + }, + "b236fc09fef7": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "b8b02a30b6b8": { + "name": "repo.searchRefs#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"repo.searchRefs\",\"params\":{\"repo\":\"id:repo-1\",\"query\":\"bug\",\"limit\":20}}" + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "be85b10635d4": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of undefined (reading 'error')", + "isRpcDeliveryUnknown": false + } + }, + "c43e80126d82": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "e2325a86e69b": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-2", + "ok": false + } + } + }, + "e97e5a589476": { + "name": "linear.listIssues#1", + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"linear.listIssues\",\"params\":{\"filter\":\"assigned\",\"limit\":50}}" + }, + "ead829dd6d03": { + "name": "linear.searchIssues#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"linear.searchIssues\",\"params\":{\"query\":\"bug\",\"limit\":50,\"workspaceId\":\"linear-workspace\"}}" + }, + "ee6fe4f97b01": { + "name": "github.listWorkItems#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"limit\":36,\"query\":\"bug\"}}" + }, + "f01419051ddf": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "f0a975a83b87": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "f32ad26605d0": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "missing", + "type": "not_found" + }, + "items": [ + { + "iid": 2, + "title": "two" + } + ] + } + } + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "fb884a9370b1": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-2", + "ok": false + } + } + }, + "fe7f60b5d785": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": [ + { + "id": "issue-3" + } + ] + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.smart-source-search-gitlab.listworkitems-1", + "checkpoints": [ + { + "id": "tw-smart-search-all-providers.prelude:github-items", + "observation": { + "sender": ["5bce68072dc3"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "36290ab254a4" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "b236fc09fef7"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "be85b10635d4" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:linear-search", + "observation": { + "sender": ["5bce68072dc3", "b236fc09fef7", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "be85b10635d4", + "linear": "a4ee5d16b4f6" + }, + "state": "6f4bd0fc6d8b", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "b236fc09fef7", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "be85b10635d4", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "82f9caba201c", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "b236fc09fef7", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "be85b10635d4", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "f0a975a83b87", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "76fa023e535f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "a25ac3f73d46" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:linear-search", + "observation": { + "sender": ["5bce68072dc3", "76fa023e535f", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "a25ac3f73d46", + "linear": "a4ee5d16b4f6" + }, + "state": "6f4bd0fc6d8b", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "76fa023e535f", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "a25ac3f73d46", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "82f9caba201c", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "76fa023e535f", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "a25ac3f73d46", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "f0a975a83b87", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "94d9f7a1e105"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f" + }, + "state": "24e67c350a20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:linear-search", + "observation": { + "sender": ["5bce68072dc3", "94d9f7a1e105", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f", + "linear": "a4ee5d16b4f6" + }, + "state": "6c647ccd3cff", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "94d9f7a1e105", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "9f9af59ae576", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "94d9f7a1e105", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "74bc6b65cdef", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "8eb709e28997"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f" + }, + "state": "24e67c350a20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:linear-search", + "observation": { + "sender": ["5bce68072dc3", "8eb709e28997", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f", + "linear": "a4ee5d16b4f6" + }, + "state": "6c647ccd3cff", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "8eb709e28997", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "9f9af59ae576", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "8eb709e28997", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "74bc6b65cdef", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "3698dc9e21e5"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f" + }, + "state": "24e67c350a20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:linear-search", + "observation": { + "sender": ["5bce68072dc3", "3698dc9e21e5", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f", + "linear": "a4ee5d16b4f6" + }, + "state": "6c647ccd3cff", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "3698dc9e21e5", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "9f9af59ae576", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "3698dc9e21e5", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "25716369cd8f", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "74bc6b65cdef", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "fb884a9370b1"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "32a7c0ae7918" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:linear-search", + "observation": { + "sender": ["5bce68072dc3", "fb884a9370b1", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "32a7c0ae7918", + "linear": "a4ee5d16b4f6" + }, + "state": "6f4bd0fc6d8b", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "fb884a9370b1", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "32a7c0ae7918", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "82f9caba201c", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "fb884a9370b1", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "32a7c0ae7918", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "f0a975a83b87", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "5b5689593188"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "f3b516f62081" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:linear-search", + "observation": { + "sender": ["5bce68072dc3", "5b5689593188", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "f3b516f62081", + "linear": "a4ee5d16b4f6" + }, + "state": "6f4bd0fc6d8b", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "5b5689593188", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "f3b516f62081", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "82f9caba201c", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "5b5689593188", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "f3b516f62081", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "f0a975a83b87", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "e2325a86e69b"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "b948e8307e81" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:linear-search", + "observation": { + "sender": ["5bce68072dc3", "e2325a86e69b", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "b948e8307e81", + "linear": "a4ee5d16b4f6" + }, + "state": "6f4bd0fc6d8b", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "e2325a86e69b", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "b948e8307e81", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "82f9caba201c", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "e2325a86e69b", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "b948e8307e81", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "f0a975a83b87", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "67ba0246dbf8"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "a947768bc0ed" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:linear-search", + "observation": { + "sender": ["5bce68072dc3", "67ba0246dbf8", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "a947768bc0ed", + "linear": "a4ee5d16b4f6" + }, + "state": "6f4bd0fc6d8b", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "67ba0246dbf8", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "a947768bc0ed", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "82f9caba201c", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "67ba0246dbf8", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "a947768bc0ed", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "f0a975a83b87", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "f01419051ddf"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "c7584e82c72f" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f01419051ddf", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "c7584e82c72f", + "linear": "a4ee5d16b4f6" + }, + "state": "6f4bd0fc6d8b", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f01419051ddf", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "c7584e82c72f", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "82f9caba201c", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f01419051ddf", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "c7584e82c72f", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "f0a975a83b87", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json new file mode 100644 index 00000000000..2979643ea9c --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json @@ -0,0 +1,1199 @@ +{ + "operation": "tasks.smart-source-search", + "family": "tasks.smart-source-search", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "767044526344237daee0a3f981a10615fbb9ebc3f45c2f6f41f9b8b16d362082", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "090ea9e6ac63": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "253629bd0d20": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "25f88995b39a": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "refs": ["main", "release"] + } + } + } + }, + "26dc3b7c8299": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-3" + } + ] + }, + "2cfd107b9660": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "36290ab254a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "3828d5880c35": { + "name": "gitlab.listWorkItems#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"state\":\"opened\",\"page\":1,\"perPage\":50,\"query\":\"bug\"}}" + }, + "3c5eceeb8463": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-5", + "ok": false + } + } + }, + "41d2452d4ebe": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "5bce68072dc3": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "items": [ + { + "number": 1, + "title": "one" + } + ] + } + } + } + }, + "6107c951646f": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "items": [ + { + "id": "issue-1" + } + ] + } + } + } + }, + "6e2d75e3bbd7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "7a91e9a2c1bb": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "957cc0c5ead6": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unexpected Linear tasks response", + "isRpcDeliveryUnknown": false + } + }, + "a4ee5d16b4f6": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-1" + } + ] + }, + "a92cd1dd05af": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "adb630f7c310": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-5", + "ok": false + } + } + }, + "b015aaf3a53a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ] + }, + "b68d510a9e89": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "b8b02a30b6b8": { + "name": "repo.searchRefs#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"repo.searchRefs\",\"params\":{\"repo\":\"id:repo-1\",\"query\":\"bug\",\"limit\":20}}" + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "c43e80126d82": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "d7c2c3caeb26": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-5", + "ok": false + } + } + }, + "e97e5a589476": { + "name": "linear.listIssues#1", + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"linear.listIssues\",\"params\":{\"filter\":\"assigned\",\"limit\":50}}" + }, + "ead829dd6d03": { + "name": "linear.searchIssues#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"linear.searchIssues\",\"params\":{\"query\":\"bug\",\"limit\":50,\"workspaceId\":\"linear-workspace\"}}" + }, + "ec10770e2214": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "ee6fe4f97b01": { + "name": "github.listWorkItems#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"limit\":36,\"query\":\"bug\"}}" + }, + "f32ad26605d0": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "missing", + "type": "not_found" + }, + "items": [ + { + "iid": 2, + "title": "two" + } + ] + } + } + } + }, + "f3a7d3f5dc3c": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true + } + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "f64e4725150b": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "f7b4f4fa8d5a": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "fe7f60b5d785": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": [ + { + "id": "issue-3" + } + ] + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.smart-source-search-linear.listissues-1", + "checkpoints": [ + { + "id": "tw-smart-search-all-providers.prelude:github-items", + "observation": { + "sender": ["5bce68072dc3"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "36290ab254a4" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.prelude:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.prelude:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.prelude:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "f3a7d3f5dc3c" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "957cc0c5ead6" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "7a91e9a2c1bb" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "957cc0c5ead6" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "ec10770e2214" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "957cc0c5ead6" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "f7b4f4fa8d5a" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "957cc0c5ead6" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "b68d510a9e89" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "957cc0c5ead6" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "3c5eceeb8463" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "32a7c0ae7918" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "adb630f7c310" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "f3b516f62081" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "d7c2c3caeb26" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "b948e8307e81" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "f64e4725150b" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "a947768bc0ed" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "090ea9e6ac63" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "c7584e82c72f" + }, + "state": "c43e80126d82", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json new file mode 100644 index 00000000000..d45e0fb6df5 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json @@ -0,0 +1,1495 @@ +{ + "operation": "tasks.smart-source-search", + "family": "tasks.smart-source-search", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "40a8ea6a916d4266bd80148e40fd817bbe80cefa02465b88d37c42cebed44f22", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0914e9c666b1": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "253629bd0d20": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "25f88995b39a": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "refs": ["main", "release"] + } + } + } + }, + "26dc3b7c8299": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-3" + } + ] + }, + "27fa02820da8": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "2cfd107b9660": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "3351dd9fcc16": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-3", + "ok": false + } + } + }, + "36290ab254a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "3828d5880c35": { + "name": "gitlab.listWorkItems#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"state\":\"opened\",\"page\":1,\"perPage\":50,\"query\":\"bug\"}}" + }, + "41d2452d4ebe": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "5a41c0588421": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true + } + } + }, + "5bce68072dc3": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "items": [ + { + "number": 1, + "title": "one" + } + ] + } + } + } + }, + "6107c951646f": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "items": [ + { + "id": "issue-1" + } + ] + } + } + } + }, + "619f7466012f": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "6e2d75e3bbd7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "78f8899bda05": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "957cc0c5ead6": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unexpected Linear tasks response", + "isRpcDeliveryUnknown": false + } + }, + "99e5be0a1b11": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "a4ee5d16b4f6": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-1" + } + ] + }, + "a92cd1dd05af": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "b015aaf3a53a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ] + }, + "b4185f815a19": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-3", + "ok": false + } + } + }, + "b8b02a30b6b8": { + "name": "repo.searchRefs#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"repo.searchRefs\",\"params\":{\"repo\":\"id:repo-1\",\"query\":\"bug\",\"limit\":20}}" + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "c2b41e1dbaf8": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "c43e80126d82": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "c770655d7a45": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "e97e5a589476": { + "name": "linear.listIssues#1", + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"linear.listIssues\",\"params\":{\"filter\":\"assigned\",\"limit\":50}}" + }, + "ead829dd6d03": { + "name": "linear.searchIssues#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"linear.searchIssues\",\"params\":{\"query\":\"bug\",\"limit\":50,\"workspaceId\":\"linear-workspace\"}}" + }, + "ee6fe4f97b01": { + "name": "github.listWorkItems#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"limit\":36,\"query\":\"bug\"}}" + }, + "f32ad26605d0": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "missing", + "type": "not_found" + }, + "items": [ + { + "iid": 2, + "title": "two" + } + ] + } + } + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "fb4807630e1d": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-3", + "ok": false + } + } + }, + "fe7f60b5d785": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": [ + { + "id": "issue-3" + } + ] + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.smart-source-search-linear.searchissues-1", + "checkpoints": [ + { + "id": "tw-smart-search-all-providers.prelude:github-items", + "observation": { + "sender": ["5bce68072dc3"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "36290ab254a4" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.prelude:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "5a41c0588421"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "5a41c0588421", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6", + "branches": "b015aaf3a53a" + }, + "state": "c2b41e1dbaf8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "5a41c0588421", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "78f8899bda05"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "78f8899bda05", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6", + "branches": "b015aaf3a53a" + }, + "state": "c2b41e1dbaf8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "78f8899bda05", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "27fa02820da8"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "27fa02820da8", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6", + "branches": "b015aaf3a53a" + }, + "state": "c2b41e1dbaf8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "27fa02820da8", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "c770655d7a45"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "c770655d7a45", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6", + "branches": "b015aaf3a53a" + }, + "state": "c2b41e1dbaf8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "c770655d7a45", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "619f7466012f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "619f7466012f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6", + "branches": "b015aaf3a53a" + }, + "state": "c2b41e1dbaf8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "619f7466012f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "957cc0c5ead6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "3351dd9fcc16"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "32a7c0ae7918" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "3351dd9fcc16", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "32a7c0ae7918", + "branches": "b015aaf3a53a" + }, + "state": "c2b41e1dbaf8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "3351dd9fcc16", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "32a7c0ae7918", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "fb4807630e1d"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "f3b516f62081" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "fb4807630e1d", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "f3b516f62081", + "branches": "b015aaf3a53a" + }, + "state": "c2b41e1dbaf8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "fb4807630e1d", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "f3b516f62081", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "b4185f815a19"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "b948e8307e81" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "b4185f815a19", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "b948e8307e81", + "branches": "b015aaf3a53a" + }, + "state": "c2b41e1dbaf8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "b4185f815a19", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "b948e8307e81", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "99e5be0a1b11"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a947768bc0ed" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "99e5be0a1b11", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a947768bc0ed", + "branches": "b015aaf3a53a" + }, + "state": "c2b41e1dbaf8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "99e5be0a1b11", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a947768bc0ed", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "0914e9c666b1"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "c7584e82c72f" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "0914e9c666b1", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "c7584e82c72f", + "branches": "b015aaf3a53a" + }, + "state": "c2b41e1dbaf8", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "0914e9c666b1", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "c7584e82c72f", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json new file mode 100644 index 00000000000..b0629033f9b --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json @@ -0,0 +1,1410 @@ +{ + "operation": "tasks.smart-source-search", + "family": "tasks.smart-source-search", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "aa06630cd902fd5dde5181bddd38f5478ae5b48044a3105b090728158aa9a621", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "071e0e8e68dc": { + "branches": [], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "253629bd0d20": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "25716369cd8f": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [] + }, + "25f88995b39a": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "refs": ["main", "release"] + } + } + } + }, + "26dc3b7c8299": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-3" + } + ] + }, + "2cfd107b9660": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "36290ab254a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "3828d5880c35": { + "name": "gitlab.listWorkItems#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"state\":\"opened\",\"page\":1,\"perPage\":50,\"query\":\"bug\"}}" + }, + "3842f5bcd677": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "39576819ef3f": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "41d2452d4ebe": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "553cf244460a": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "59e25358865a": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-4", + "ok": false + } + } + }, + "5bce68072dc3": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "items": [ + { + "number": 1, + "title": "one" + } + ] + } + } + } + }, + "5d85e47efa46": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-4", + "ok": false + } + } + }, + "5ff512429b6c": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true + } + } + }, + "6107c951646f": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "items": [ + { + "id": "issue-1" + } + ] + } + } + } + }, + "6e2d75e3bbd7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "a4263a13d324": { + "branches": [], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "a4ee5d16b4f6": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-1" + } + ] + }, + "a92cd1dd05af": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "b015aaf3a53a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ] + }, + "b2d9361f1d3d": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "b721d1733537": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "b8b02a30b6b8": { + "name": "repo.searchRefs#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"repo.searchRefs\",\"params\":{\"repo\":\"id:repo-1\",\"query\":\"bug\",\"limit\":20}}" + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "c30c54d734bc": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of undefined (reading 'refDetails')", + "isRpcDeliveryUnknown": false + } + }, + "c43e80126d82": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "d1572a7d1ddb": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "d4061d056a75": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-4", + "ok": false + } + } + }, + "e6d2fd7367d3": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "e97e5a589476": { + "name": "linear.listIssues#1", + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"linear.listIssues\",\"params\":{\"filter\":\"assigned\",\"limit\":50}}" + }, + "ead829dd6d03": { + "name": "linear.searchIssues#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"linear.searchIssues\",\"params\":{\"query\":\"bug\",\"limit\":50,\"workspaceId\":\"linear-workspace\"}}" + }, + "ee6fe4f97b01": { + "name": "github.listWorkItems#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"limit\":36,\"query\":\"bug\"}}" + }, + "f32ad26605d0": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "missing", + "type": "not_found" + }, + "items": [ + { + "iid": 2, + "title": "two" + } + ] + } + } + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "f582af356003": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of null (reading 'refDetails')", + "isRpcDeliveryUnknown": false + } + }, + "fe7f60b5d785": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": [ + { + "id": "issue-3" + } + ] + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.smart-source-search-repo.searchrefs-1", + "checkpoints": [ + { + "id": "tw-smart-search-all-providers.prelude:github-items", + "observation": { + "sender": ["5bce68072dc3"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "36290ab254a4" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.prelude:gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.prelude:linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.normal:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "5ff512429b6c"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "c30c54d734bc" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-absent:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "5ff512429b6c", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "c30c54d734bc", + "linear-assigned": "26dc3b7c8299" + }, + "state": "d1572a7d1ddb", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "553cf244460a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "f582af356003" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.result-null:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "553cf244460a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "f582af356003", + "linear-assigned": "26dc3b7c8299" + }, + "state": "d1572a7d1ddb", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "b2d9361f1d3d"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "25716369cd8f" + }, + "state": "a4263a13d324", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-ok-missing:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "b2d9361f1d3d", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "25716369cd8f", + "linear-assigned": "26dc3b7c8299" + }, + "state": "071e0e8e68dc", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "b721d1733537"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "25716369cd8f" + }, + "state": "a4263a13d324", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-string-error:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "b721d1733537", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "25716369cd8f", + "linear-assigned": "26dc3b7c8299" + }, + "state": "071e0e8e68dc", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "3842f5bcd677"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "25716369cd8f" + }, + "state": "a4263a13d324", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.inner-false-object-error:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "3842f5bcd677", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "25716369cd8f", + "linear-assigned": "26dc3b7c8299" + }, + "state": "071e0e8e68dc", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "59e25358865a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "32a7c0ae7918" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "59e25358865a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "32a7c0ae7918", + "linear-assigned": "26dc3b7c8299" + }, + "state": "d1572a7d1ddb", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "d4061d056a75"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "f3b516f62081" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.outer-refused-no-message:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "d4061d056a75", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "f3b516f62081", + "linear-assigned": "26dc3b7c8299" + }, + "state": "d1572a7d1ddb", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "5d85e47efa46"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b948e8307e81" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.method-not-found:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "5d85e47efa46", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b948e8307e81", + "linear-assigned": "26dc3b7c8299" + }, + "state": "d1572a7d1ddb", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "39576819ef3f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "a947768bc0ed" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "39576819ef3f", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "a947768bc0ed", + "linear-assigned": "26dc3b7c8299" + }, + "state": "d1572a7d1ddb", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "e6d2fd7367d3"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "c7584e82c72f" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "tw-smart-search-all-providers.transport-rejection-no-message:linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "e6d2fd7367d3", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "c7584e82c72f", + "linear-assigned": "26dc3b7c8299" + }, + "state": "d1572a7d1ddb", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json new file mode 100644 index 00000000000..4840f31a2b0 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json @@ -0,0 +1,976 @@ +{ + "operation": "tasks.workspace-source", + "family": "tasks.workspace-source", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "9e91d46870cd69279cc7d8ebfd317ab8b13136ccff9662876c7601a3a83ecafe", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0045016f4149": { + "branchError": "Cannot read properties of null (reading 'refDetails')", + "branches": [], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "1d9a7d969446": { + "name": "workspaceSparsePresetsLoaded", + "value": true + }, + "273f4074a9b5": { + "name": "workspaceSparsePresetsLoaded", + "value": false + }, + "28f23529596e": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "2bd164983c10": { + "branchError": "outer refused", + "branches": [], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "2e554aeab5d0": { + "branchError": "transport failure", + "branches": [], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "35afa5cb107f": { + "name": "workspaceBaseBranchLoading", + "value": false + }, + "395368dea8ff": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "refs": ["main"] + } + } + } + }, + "46027e62015d": { + "name": "repo.searchRefs#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"repo.searchRefs\",\"params\":{\"repo\":\"id:repo-1\",\"query\":\"main\",\"limit\":20}}" + }, + "46f2f1c9bc6a": { + "name": "workspaceBaseBranchError", + "value": "transport failure" + }, + "4856f62b3650": { + "name": "workspaceSparsePresets", + "value": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ] + }, + "4cedb91a2f7a": { + "name": "repo.sparsePresets#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.sparsePresets\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "52925a303ed6": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "5485811c08ca": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "57f06e6e349e": { + "branchError": "", + "branches": [], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "58cb95babab2": { + "name": "workspaceBaseBranchLoading", + "value": true + }, + "5b0e628f442c": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "5f1c84e00d4f": { + "name": "workspaceBaseBranchResults", + "value": [] + }, + "6c344c5f4ac0": { + "name": "workspaceBaseBranchError", + "value": "" + }, + "7444e76d58f7": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "8353b8e1a426": { + "name": "workspaceSparsePresetsLoading", + "value": true + }, + "846e910f6579": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "8dbe7ea87a41": { + "name": "workspaceBaseBranchResults", + "value": [ + { + "localBranchName": "main", + "refName": "main" + } + ] + }, + "914268bb0636": { + "name": "workspaceBaseBranchError", + "value": "outer refused" + }, + "9357f7ea8445": { + "name": "workspaceSparsePresetId", + "value": { + "$rpc": "null" + } + }, + "94cafc85a34d": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "b78bcf7ca596": { + "branchError": "", + "branches": [ + { + "localBranchName": "main", + "refName": "main" + } + ], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "bb1a94f8cb3f": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-2", + "ok": false + } + } + }, + "bd26306458d2": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-2", + "ok": false + } + } + }, + "bd93f3a9862f": { + "branchError": "Unknown method", + "branches": [], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "c8d4d05367d6": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ] + } + } + } + }, + "cfc8af2a7169": { + "name": "workspaceSparsePresetsLoading", + "value": false + }, + "dba381378b08": { + "name": "workspaceSparsePresetsError", + "value": "" + }, + "e4bad139cb5f": { + "branchError": "Cannot read properties of undefined (reading 'refDetails')", + "branches": [], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "e883c3f737f1": { + "name": "workspaceBaseBranchError", + "value": "Unknown method" + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ec51e489da58": { + "name": "workspaceBaseBranchError", + "value": "Cannot read properties of null (reading 'refDetails')" + }, + "f6f9a9765c0c": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "fcca5f73b480": { + "name": "workspaceBaseBranchError", + "value": "Cannot read properties of undefined (reading 'refDetails')" + } + }, + "recording": { + "scenario": "matrix-tasks.workspace-source-repo.searchrefs-1", + "checkpoints": [ + { + "id": "tw-workspace-source-presets.prelude:presets-loaded", + "observation": { + "sender": ["c8d4d05367d6"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "57f06e6e349e", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.normal:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "b78bcf7ca596", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.result-absent:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "846e910f6579"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "e4bad139cb5f", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "5f1c84e00d4f", + "fcca5f73b480", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.result-null:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "28f23529596e"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "0045016f4149", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "5f1c84e00d4f", + "ec51e489da58", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.inner-ok-missing:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "5485811c08ca"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "57f06e6e349e", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "5f1c84e00d4f", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.inner-false-string-error:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "f6f9a9765c0c"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "57f06e6e349e", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "5f1c84e00d4f", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.inner-false-object-error:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "94cafc85a34d"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "57f06e6e349e", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "5f1c84e00d4f", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.outer-refused:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "bb1a94f8cb3f"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "2bd164983c10", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "5f1c84e00d4f", + "914268bb0636", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.outer-refused-no-message:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "7444e76d58f7"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "57f06e6e349e", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "5f1c84e00d4f", + "6c344c5f4ac0", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.method-not-found:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "bd26306458d2"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "bd93f3a9862f", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "5f1c84e00d4f", + "e883c3f737f1", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.transport-rejection:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "5b0e628f442c"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "2e554aeab5d0", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "5f1c84e00d4f", + "46f2f1c9bc6a", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.transport-rejection-no-message:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "52925a303ed6"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "57f06e6e349e", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "5f1c84e00d4f", + "6c344c5f4ac0", + "35afa5cb107f" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json new file mode 100644 index 00000000000..6ef143268c6 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json @@ -0,0 +1,1265 @@ +{ + "operation": "tasks.workspace-source", + "family": "tasks.workspace-source", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "2d4a681bffbc5ff9d3040ea0d6bb2603ee940c3c497269d0b63caca564fb25e1", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0e0e1c74c796": { + "branchError": "", + "branches": [], + "presets": [], + "presetsError": "transport failure", + "presetsLoaded": false + }, + "1d9a7d969446": { + "name": "workspaceSparsePresetsLoaded", + "value": true + }, + "2399e995a370": { + "name": "workspaceSparsePresets", + "value": [] + }, + "273f4074a9b5": { + "name": "workspaceSparsePresetsLoaded", + "value": false + }, + "2d69fe330484": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "35afa5cb107f": { + "name": "workspaceBaseBranchLoading", + "value": false + }, + "395368dea8ff": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "refs": ["main"] + } + } + } + }, + "3dcbacca6ef0": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "46027e62015d": { + "name": "repo.searchRefs#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"repo.searchRefs\",\"params\":{\"repo\":\"id:repo-1\",\"query\":\"main\",\"limit\":20}}" + }, + "4856f62b3650": { + "name": "workspaceSparsePresets", + "value": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ] + }, + "4c7522c66d03": { + "name": "workspaceSparsePresetsError", + "value": "transport failure" + }, + "4cedb91a2f7a": { + "name": "repo.sparsePresets#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.sparsePresets\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "51396e45f193": { + "name": "workspaceSparsePresetsError", + "value": "Cannot read properties of undefined (reading 'presets')" + }, + "513bb01f2f25": { + "branchError": "", + "branches": [ + { + "localBranchName": "main", + "refName": "main" + } + ], + "presets": [], + "presetsError": "", + "presetsLoaded": true + }, + "57f06e6e349e": { + "branchError": "", + "branches": [], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "58cb95babab2": { + "name": "workspaceBaseBranchLoading", + "value": true + }, + "5cc2ef1617e8": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "5e895d7d4949": { + "branchError": "", + "branches": [ + { + "localBranchName": "main", + "refName": "main" + } + ], + "presets": [], + "presetsError": "transport failure", + "presetsLoaded": false + }, + "5f1c84e00d4f": { + "name": "workspaceBaseBranchResults", + "value": [] + }, + "62bc28c39ffc": { + "branchError": "", + "branches": [], + "presets": [], + "presetsError": "", + "presetsLoaded": false + }, + "6c344c5f4ac0": { + "name": "workspaceBaseBranchError", + "value": "" + }, + "83043f6bd49a": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "8353b8e1a426": { + "name": "workspaceSparsePresetsLoading", + "value": true + }, + "841927d71fc6": { + "name": "workspaceSparsePresetsError", + "value": "outer refused" + }, + "844c1ccf1f9a": { + "branchError": "", + "branches": [ + { + "localBranchName": "main", + "refName": "main" + } + ], + "presets": [], + "presetsError": "Cannot read properties of undefined (reading 'presets')", + "presetsLoaded": false + }, + "8b4d034d6e9e": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "8dbe7ea87a41": { + "name": "workspaceBaseBranchResults", + "value": [ + { + "localBranchName": "main", + "refName": "main" + } + ] + }, + "90bd9a937fe0": { + "branchError": "", + "branches": [ + { + "localBranchName": "main", + "refName": "main" + } + ], + "presets": [], + "presetsError": "", + "presetsLoaded": false + }, + "9357f7ea8445": { + "name": "workspaceSparsePresetId", + "value": { + "$rpc": "null" + } + }, + "96f5a578e45b": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "981cb584cfe3": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "982d70c476ea": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "a18f0cdbc6fe": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "aaaeee84b4d2": { + "name": "workspaceSparsePresetsError", + "value": "Cannot read properties of null (reading 'presets')" + }, + "b78bcf7ca596": { + "branchError": "", + "branches": [ + { + "localBranchName": "main", + "refName": "main" + } + ], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "bf004df2bf3d": { + "branchError": "", + "branches": [ + { + "localBranchName": "main", + "refName": "main" + } + ], + "presets": [], + "presetsError": "Cannot read properties of null (reading 'presets')", + "presetsLoaded": false + }, + "c58cdfec29bd": { + "branchError": "", + "branches": [], + "presets": [], + "presetsError": "", + "presetsLoaded": true + }, + "c8d4d05367d6": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ] + } + } + } + }, + "c909c6a474d9": { + "branchError": "", + "branches": [], + "presets": [], + "presetsError": "Cannot read properties of undefined (reading 'presets')", + "presetsLoaded": false + }, + "c9379c8a3ba8": { + "branchError": "", + "branches": [], + "presets": [], + "presetsError": "outer refused", + "presetsLoaded": false + }, + "c9e6bb8f5e61": { + "branchError": "", + "branches": [], + "presets": [], + "presetsError": "Cannot read properties of null (reading 'presets')", + "presetsLoaded": false + }, + "ce4aab89eed0": { + "branchError": "", + "branches": [], + "presets": [], + "presetsError": "Unknown method", + "presetsLoaded": false + }, + "ce7d06abf495": { + "branchError": "", + "branches": [ + { + "localBranchName": "main", + "refName": "main" + } + ], + "presets": [], + "presetsError": "Unknown method", + "presetsLoaded": false + }, + "cfc8af2a7169": { + "name": "workspaceSparsePresetsLoading", + "value": false + }, + "d075e587b820": { + "branchError": "", + "branches": [ + { + "localBranchName": "main", + "refName": "main" + } + ], + "presets": [], + "presetsError": "outer refused", + "presetsLoaded": false + }, + "db27fad68ce2": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "dba381378b08": { + "name": "workspaceSparsePresetsError", + "value": "" + }, + "ea260eacb1db": { + "name": "workspaceSparsePresetsError", + "value": "Unknown method" + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + } + }, + "recording": { + "scenario": "matrix-tasks.workspace-source-repo.sparsepresets-1", + "checkpoints": [ + { + "id": "tw-workspace-source-presets.normal:presets-loaded", + "observation": { + "sender": ["c8d4d05367d6"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "57f06e6e349e", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.normal:branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "b78bcf7ca596", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.result-absent:presets-loaded", + "observation": { + "sender": ["981cb584cfe3"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "c909c6a474d9", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "51396e45f193", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.result-absent:branches-loaded", + "observation": { + "sender": ["981cb584cfe3", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "844c1ccf1f9a", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "51396e45f193", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.result-null:presets-loaded", + "observation": { + "sender": ["a18f0cdbc6fe"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "c9e6bb8f5e61", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "aaaeee84b4d2", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.result-null:branches-loaded", + "observation": { + "sender": ["a18f0cdbc6fe", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "bf004df2bf3d", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "aaaeee84b4d2", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.inner-ok-missing:presets-loaded", + "observation": { + "sender": ["3dcbacca6ef0"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "c58cdfec29bd", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.inner-ok-missing:branches-loaded", + "observation": { + "sender": ["3dcbacca6ef0", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "513bb01f2f25", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.inner-false-string-error:presets-loaded", + "observation": { + "sender": ["982d70c476ea"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "c58cdfec29bd", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.inner-false-string-error:branches-loaded", + "observation": { + "sender": ["982d70c476ea", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "513bb01f2f25", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.inner-false-object-error:presets-loaded", + "observation": { + "sender": ["8b4d034d6e9e"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "c58cdfec29bd", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.inner-false-object-error:branches-loaded", + "observation": { + "sender": ["8b4d034d6e9e", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "513bb01f2f25", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.outer-refused:presets-loaded", + "observation": { + "sender": ["5cc2ef1617e8"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "c9379c8a3ba8", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "841927d71fc6", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.outer-refused:branches-loaded", + "observation": { + "sender": ["5cc2ef1617e8", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "d075e587b820", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "841927d71fc6", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.outer-refused-no-message:presets-loaded", + "observation": { + "sender": ["db27fad68ce2"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "62bc28c39ffc", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "dba381378b08", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.outer-refused-no-message:branches-loaded", + "observation": { + "sender": ["db27fad68ce2", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "90bd9a937fe0", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "dba381378b08", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.method-not-found:presets-loaded", + "observation": { + "sender": ["83043f6bd49a"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "ce4aab89eed0", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "ea260eacb1db", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.method-not-found:branches-loaded", + "observation": { + "sender": ["83043f6bd49a", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "ce7d06abf495", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "ea260eacb1db", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.transport-rejection:presets-loaded", + "observation": { + "sender": ["96f5a578e45b"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "0e0e1c74c796", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "4c7522c66d03", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.transport-rejection:branches-loaded", + "observation": { + "sender": ["96f5a578e45b", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "5e895d7d4949", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "4c7522c66d03", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + }, + { + "id": "tw-workspace-source-presets.transport-rejection-no-message:presets-loaded", + "observation": { + "sender": ["2d69fe330484"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "62bc28c39ffc", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "dba381378b08", + "cfc8af2a7169" + ] + } + }, + { + "id": "tw-workspace-source-presets.transport-rejection-no-message:branches-loaded", + "observation": { + "sender": ["2d69fe330484", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "90bd9a937fe0", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "dba381378b08", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json new file mode 100644 index 00000000000..7ac2c78b995 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json @@ -0,0 +1,940 @@ +{ + "operation": "tasks.workspace-sparse", + "family": "tasks.workspace-sparse", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "3d4637406e2d658b72f73153f0a5e176cb8b143593b72bce0cdf979bc6e4cbdd", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "1923ab7dba76": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-2", + "ok": false + } + } + }, + "1d9a7d969446": { + "name": "workspaceSparsePresetsLoaded", + "value": true + }, + "1f453ea83df7": { + "presets": [], + "presetsError": "transport failure", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "23e798f4b47c": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "312ed3cbf468": { + "name": "workspaceSparsePresetId", + "value": "p1" + }, + "404305aa2e3a": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "42bbd034563e": { + "name": "workspaceSparseDraft", + "value": { + "$rpc": "null" + } + }, + "4856f62b3650": { + "name": "workspaceSparsePresets", + "value": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ] + }, + "4c7522c66d03": { + "name": "workspaceSparsePresetsError", + "value": "transport failure" + }, + "4d66e995ff47": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "4e1228f5e0a8": { + "name": "workspaceSparsePresetsError", + "value": "Cannot read properties of null (reading 'preset')" + }, + "5c44ff5f6877": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "preset": { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + } + } + } + }, + "74c1230400a6": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-2", + "ok": false + } + } + }, + "7993762437ad": { + "name": "workspaceSparsePresetsError", + "value": "Connection closed" + }, + "7fd0cde62993": { + "name": "workspaceSparseSaving", + "value": false + }, + "841927d71fc6": { + "name": "workspaceSparsePresetsError", + "value": "outer refused" + }, + "89aa7a3bd619": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "state": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + } + } + } + }, + "8e410711e308": { + "presets": [], + "presetsError": "Cannot read properties of undefined (reading 'preset')", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "921f72d7827e": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "92b857799ffd": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "95295c6eaa8d": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "990a149dc1be": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "bcfc7df6e4f2": { + "presets": [], + "presetsError": "", + "saving": true, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "bd0266b23771": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "befb68fa6c76": { + "presets": [], + "presetsError": "Failed to save sparse preset.", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "c33fee0d8294": { + "presets": [], + "presetsError": "Unknown method", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "c5a3f70f9b02": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "cea9d7e8986e": { + "name": "workspaceSparseSaving", + "value": true + }, + "d14de7ce4d84": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "d74bc3778ca8": { + "presets": [], + "presetsError": "outer refused", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "da3a01640280": { + "name": "workspaceSparsePresetsError", + "value": "Failed to save sparse preset." + }, + "dba381378b08": { + "name": "workspaceSparsePresetsError", + "value": "" + }, + "dd63325a7802": { + "name": "workspaceSparsePresetsError", + "value": "Cannot read properties of undefined (reading 'preset')" + }, + "e52233a9ff71": { + "presets": [], + "presetsError": "Cannot read properties of null (reading 'preset')", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "ea260eacb1db": { + "name": "workspaceSparsePresetsError", + "value": "Unknown method" + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ee3a941d5e9c": { + "presets": [], + "presetsError": "", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "f4d4ba362712": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "f9dfbe0c0ea7": { + "name": "ssh.getState#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.getState\",\"params\":{\"targetId\":\"ssh-1\"}}" + }, + "fd758406cc2c": { + "name": "repo.saveSparsePreset#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"repo.saveSparsePreset\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"docs\",\"directories\":[\"docs\"]}}" + } + }, + "recording": { + "scenario": "matrix-tasks.workspace-sparse-repo.savesparsepreset-1", + "checkpoints": [ + { + "id": "tw-workspace-sparse-saved.prelude:ssh-state-read", + "observation": { + "sender": ["89aa7a3bd619"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "ee3a941d5e9c", + "effects": ["921f72d7827e"] + } + }, + { + "id": "tw-workspace-sparse-saved.prelude:cleanup", + "observation": { + "sender": ["89aa7a3bd619", "4d66e995ff47"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "bcfc7df6e4f2", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "7993762437ad", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.normal:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "404305aa2e3a", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.result-absent:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "95295c6eaa8d"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "8e410711e308", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "dd63325a7802", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.result-null:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "d14de7ce4d84"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "e52233a9ff71", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "4e1228f5e0a8", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.inner-ok-missing:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "92b857799ffd"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "befb68fa6c76", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "da3a01640280", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.inner-false-string-error:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "bd0266b23771"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "befb68fa6c76", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "da3a01640280", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.inner-false-object-error:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "c5a3f70f9b02"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "befb68fa6c76", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "da3a01640280", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.outer-refused:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "1923ab7dba76"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "d74bc3778ca8", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "841927d71fc6", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.outer-refused-no-message:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "990a149dc1be"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "ee3a941d5e9c", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "dba381378b08", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.method-not-found:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "74c1230400a6"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "c33fee0d8294", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "ea260eacb1db", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.transport-rejection:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "f4d4ba362712"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "1f453ea83df7", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "4c7522c66d03", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.transport-rejection-no-message:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "23e798f4b47c"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "ee3a941d5e9c", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "dba381378b08", + "7fd0cde62993" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json new file mode 100644 index 00000000000..aad4c7d9fe7 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json @@ -0,0 +1,1164 @@ +{ + "operation": "tasks.workspace-sparse", + "family": "tasks.workspace-sparse", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "3453024581230908d1e0e9335f5310e7b4ae03faf50d93654b9ff28c464fb95f", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0a16839c6f87": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "0eabd872f405": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "14db652edf02": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "1703db1e81e4": { + "name": "workspaceSshState", + "value": { + "error": "Cannot read properties of undefined (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "1d9a7d969446": { + "name": "workspaceSparsePresetsLoaded", + "value": true + }, + "25352a4de532": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "disconnected", + "targetId": "ssh-1" + } + }, + "2d910059043a": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "312ed3cbf468": { + "name": "workspaceSparsePresetId", + "value": "p1" + }, + "404305aa2e3a": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "42bbd034563e": { + "name": "workspaceSparseDraft", + "value": { + "$rpc": "null" + } + }, + "44ca8518769a": { + "presets": [], + "presetsError": "", + "saving": false, + "ssh": { + "error": "Cannot read properties of undefined (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "4856f62b3650": { + "name": "workspaceSparsePresets", + "value": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ] + }, + "57be9babbecd": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "saving": false, + "ssh": { + "error": "Unknown method", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "5c44ff5f6877": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "preset": { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + } + } + } + }, + "68ca812c8120": { + "presets": [], + "presetsError": "", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "disconnected", + "targetId": "ssh-1" + } + }, + "6daeb33f37f8": { + "presets": [], + "presetsError": "", + "saving": false, + "ssh": { + "error": "transport failure", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "7a26c9dceb4c": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "saving": false, + "ssh": { + "error": "Cannot read properties of undefined (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "7e5ba73897a1": { + "presets": [], + "presetsError": "", + "saving": false, + "ssh": { + "error": "Unknown method", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "7fd0cde62993": { + "name": "workspaceSparseSaving", + "value": false + }, + "80431b2fc9cd": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "saving": false, + "ssh": { + "error": "Cannot read properties of null (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "813df5a46a4a": { + "presets": [], + "presetsError": "", + "saving": false, + "ssh": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "81af687a998a": { + "presets": [], + "presetsError": "", + "saving": false, + "ssh": { + "error": "Cannot read properties of null (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "86cc01b1e541": { + "name": "workspaceSshState", + "value": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "89aa7a3bd619": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "state": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + } + } + } + }, + "9164e806ca12": { + "name": "workspaceSshState", + "value": { + "error": "Cannot read properties of null (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "921f72d7827e": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "9367b086d487": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "saving": false, + "ssh": { + "error": "outer refused", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "93dfd351c771": { + "name": "workspaceSshState", + "value": { + "error": "outer refused", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "a16210531185": { + "presets": [], + "presetsError": "", + "saving": false, + "ssh": { + "error": "outer refused", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "a209f2c7160e": { + "name": "workspaceSshState", + "value": { + "error": "Unknown method", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "b09dd4915f43": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "b705ba88a562": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "b7fa4557dcfa": { + "name": "workspaceSshState", + "value": { + "error": "transport failure", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "cd050477e049": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "saving": false, + "ssh": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "cea9d7e8986e": { + "name": "workspaceSparseSaving", + "value": true + }, + "d0fad8f739ca": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "dba381378b08": { + "name": "workspaceSparsePresetsError", + "value": "" + }, + "e18278fce524": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ee3a941d5e9c": { + "presets": [], + "presetsError": "", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "ef27a7ecb258": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "saving": false, + "ssh": { + "error": "transport failure", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "f36f17f8d448": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "f7885da6b9c0": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "disconnected", + "targetId": "ssh-1" + } + }, + "f9dfbe0c0ea7": { + "name": "ssh.getState#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.getState\",\"params\":{\"targetId\":\"ssh-1\"}}" + }, + "fd758406cc2c": { + "name": "repo.saveSparsePreset#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"repo.saveSparsePreset\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"docs\",\"directories\":[\"docs\"]}}" + }, + "ff6c3161dcc7": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.workspace-sparse-ssh.getstate-1", + "checkpoints": [ + { + "id": "tw-workspace-sparse-saved.normal:ssh-state-read", + "observation": { + "sender": ["89aa7a3bd619"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "ee3a941d5e9c", + "effects": ["921f72d7827e"] + } + }, + { + "id": "tw-workspace-sparse-saved.normal:preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "404305aa2e3a", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.result-absent:ssh-state-read", + "observation": { + "sender": ["14db652edf02"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "44ca8518769a", + "effects": ["1703db1e81e4"] + } + }, + { + "id": "tw-workspace-sparse-saved.result-absent:preset-saved", + "observation": { + "sender": ["14db652edf02", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "7a26c9dceb4c", + "effects": [ + "1703db1e81e4", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.result-null:ssh-state-read", + "observation": { + "sender": ["0eabd872f405"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "81af687a998a", + "effects": ["9164e806ca12"] + } + }, + { + "id": "tw-workspace-sparse-saved.result-null:preset-saved", + "observation": { + "sender": ["0eabd872f405", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "80431b2fc9cd", + "effects": [ + "9164e806ca12", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.inner-ok-missing:ssh-state-read", + "observation": { + "sender": ["0a16839c6f87"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "68ca812c8120", + "effects": ["25352a4de532"] + } + }, + { + "id": "tw-workspace-sparse-saved.inner-ok-missing:preset-saved", + "observation": { + "sender": ["0a16839c6f87", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "f7885da6b9c0", + "effects": [ + "25352a4de532", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.inner-false-string-error:ssh-state-read", + "observation": { + "sender": ["b09dd4915f43"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "68ca812c8120", + "effects": ["25352a4de532"] + } + }, + { + "id": "tw-workspace-sparse-saved.inner-false-string-error:preset-saved", + "observation": { + "sender": ["b09dd4915f43", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "f7885da6b9c0", + "effects": [ + "25352a4de532", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.inner-false-object-error:ssh-state-read", + "observation": { + "sender": ["e18278fce524"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "68ca812c8120", + "effects": ["25352a4de532"] + } + }, + { + "id": "tw-workspace-sparse-saved.inner-false-object-error:preset-saved", + "observation": { + "sender": ["e18278fce524", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "f7885da6b9c0", + "effects": [ + "25352a4de532", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.outer-refused:ssh-state-read", + "observation": { + "sender": ["d0fad8f739ca"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "a16210531185", + "effects": ["93dfd351c771"] + } + }, + { + "id": "tw-workspace-sparse-saved.outer-refused:preset-saved", + "observation": { + "sender": ["d0fad8f739ca", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "9367b086d487", + "effects": [ + "93dfd351c771", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.outer-refused-no-message:ssh-state-read", + "observation": { + "sender": ["ff6c3161dcc7"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "813df5a46a4a", + "effects": ["86cc01b1e541"] + } + }, + { + "id": "tw-workspace-sparse-saved.outer-refused-no-message:preset-saved", + "observation": { + "sender": ["ff6c3161dcc7", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "cd050477e049", + "effects": [ + "86cc01b1e541", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.method-not-found:ssh-state-read", + "observation": { + "sender": ["b705ba88a562"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "7e5ba73897a1", + "effects": ["a209f2c7160e"] + } + }, + { + "id": "tw-workspace-sparse-saved.method-not-found:preset-saved", + "observation": { + "sender": ["b705ba88a562", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "57be9babbecd", + "effects": [ + "a209f2c7160e", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.transport-rejection:ssh-state-read", + "observation": { + "sender": ["2d910059043a"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "6daeb33f37f8", + "effects": ["b7fa4557dcfa"] + } + }, + { + "id": "tw-workspace-sparse-saved.transport-rejection:preset-saved", + "observation": { + "sender": ["2d910059043a", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "ef27a7ecb258", + "effects": [ + "b7fa4557dcfa", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + }, + { + "id": "tw-workspace-sparse-saved.transport-rejection-no-message:ssh-state-read", + "observation": { + "sender": ["f36f17f8d448"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "813df5a46a4a", + "effects": ["86cc01b1e541"] + } + }, + { + "id": "tw-workspace-sparse-saved.transport-rejection-no-message:preset-saved", + "observation": { + "sender": ["f36f17f8d448", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "cd050477e049", + "effects": [ + "86cc01b1e541", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json new file mode 100644 index 00000000000..9ae9fe3d7d8 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json @@ -0,0 +1,566 @@ +{ + "operation": "tasks.workspace-ssh-local", + "family": "tasks.workspace-ssh-local", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "28f7ba289c188bfc121ef7b969133711189e887fbfc7b37c8a5401ea7f30b56a", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "00d70c40c34c": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "0846bea730cf": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "1317fc33bdbe": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "163b91b6fe9c": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "327b46fb8bef": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "41b0d115f434": { + "name": "workspaceDetectedAgentIds", + "value": { + "$rpc": "null" + } + }, + "6e5fcf24648d": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "70d128c20ae4": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "71225024ccf5": { + "agent": "claude", + "connecting": false, + "detected": [], + "setup": "unresolved", + "ssh": { + "$rpc": "null" + } + }, + "7400f4eebe66": { + "agent": "claude", + "connecting": false, + "detected": ["codex", "claude"], + "setup": "unresolved", + "ssh": { + "$rpc": "null" + } + }, + "87d7d24a30d2": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "9f152ed6e897": { + "name": "workspaceDetectedAgentIds", + "value": [] + }, + "cb93b17470e8": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": ["codex", "claude"] + } + } + }, + "cbb858a786ac": { + "name": "workspaceDetectedAgentIds", + "value": ["codex", "claude"] + }, + "cf32edc950ac": { + "name": "preflight.detectAgents#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" + }, + "ea709e13f0f0": { + "name": "workspaceAgentOverridden", + "value": false + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ed6189938d78": { + "name": "workspaceAgent", + "value": "claude" + }, + "fb640b2bca4c": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "fbb9eef78275": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.workspace-ssh-local-preflight.detectagents-1", + "checkpoints": [ + { + "id": "tw-workspace-ssh-local-agents.normal:local-agents-detected", + "observation": { + "sender": ["cb93b17470e8"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "7400f4eebe66", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "cbb858a786ac"] + } + }, + { + "id": "tw-workspace-ssh-local-agents.result-absent:local-agents-detected", + "observation": { + "sender": ["6e5fcf24648d"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-local-agents.result-null:local-agents-detected", + "observation": { + "sender": ["1317fc33bdbe"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-local-agents.inner-ok-missing:local-agents-detected", + "observation": { + "sender": ["327b46fb8bef"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-local-agents.inner-false-string-error:local-agents-detected", + "observation": { + "sender": ["0846bea730cf"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-local-agents.inner-false-object-error:local-agents-detected", + "observation": { + "sender": ["00d70c40c34c"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-local-agents.outer-refused:local-agents-detected", + "observation": { + "sender": ["fb640b2bca4c"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-local-agents.outer-refused-no-message:local-agents-detected", + "observation": { + "sender": ["163b91b6fe9c"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-local-agents.method-not-found:local-agents-detected", + "observation": { + "sender": ["87d7d24a30d2"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-local-agents.transport-rejection:local-agents-detected", + "observation": { + "sender": ["fbb9eef78275"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-local-agents.transport-rejection-no-message:local-agents-detected", + "observation": { + "sender": ["70d128c20ae4"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json new file mode 100644 index 00000000000..ea84ad9ce8a --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json @@ -0,0 +1,1268 @@ +{ + "operation": "tasks.workspace-ssh", + "family": "tasks.workspace-ssh", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "35771ab92d0d4ff44a1dd6e5f1e5d3137570a2e013bddea5ca77ecac7989ed53", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "02a3532637b4": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "07d4c9b0eaf2": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "0ba2cee4b538": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + } + }, + "162a699815c1": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "1739575ac53e": { + "name": "workspaceSshConnecting", + "value": true + }, + "17e35b25d15d": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": ["codex"] + } + } + }, + "18e6a3ac6471": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "$rpc": "null" + } + }, + "19b6093097ff": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "227c671c491b": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "3571f351281f": { + "name": "workspaceDetectedAgentIds", + "value": ["codex"] + }, + "37921d9fdeb7": { + "name": "preflight.detectRemoteAgents#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" + }, + "41b0d115f434": { + "name": "workspaceDetectedAgentIds", + "value": { + "$rpc": "null" + } + }, + "43ead075ce12": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "43fd3e2f4b53": { + "name": "workspaceSshConnecting", + "value": false + }, + "71225024ccf5": { + "agent": "claude", + "connecting": false, + "detected": [], + "setup": "unresolved", + "ssh": { + "$rpc": "null" + } + }, + "71d817ffdd81": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "state": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + } + } + } + }, + "7a1b524f17d0": { + "agent": "claude", + "connecting": false, + "detected": [], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "7c9498659f58": { + "name": "ssh.connect#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}" + }, + "80a4af19f556": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "hooks": { + "scripts": { + "setup": " pnpm install " + } + }, + "setupRunPolicy": "ask", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + } + } + } + }, + "860046b4ce30": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "90dce4861972": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "921f72d7827e": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "95dee1165f95": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "9f0676ba0d67": { + "agent": "claude", + "connecting": false, + "detected": [], + "setup": "unresolved", + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "9f152ed6e897": { + "name": "workspaceDetectedAgentIds", + "value": [] + }, + "a1f755a38636": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "c5eeac27af29": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "ea709e13f0f0": { + "name": "workspaceAgentOverridden", + "value": false + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ed6189938d78": { + "name": "workspaceAgent", + "value": "claude" + }, + "f0a9f62da106": { + "name": "repo.hooks#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"repo.hooks\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "fbfdbb919268": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connecting", + "targetId": "ssh-1" + } + }, + "fd04a7852302": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.workspace-ssh-preflight.detectremoteagents-1", + "checkpoints": [ + { + "id": "tw-workspace-ssh-connected.normal:agents-detected", + "observation": { + "sender": ["17e35b25d15d"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "18e6a3ac6471", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "3571f351281f"] + } + }, + { + "id": "tw-workspace-ssh-connected.normal:connected", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.normal:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "43ead075ce12", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.result-absent:agents-detected", + "observation": { + "sender": ["90dce4861972"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-connected.result-absent:connected", + "observation": { + "sender": ["90dce4861972", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "9f0676ba0d67", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.result-absent:setup-prompted", + "observation": { + "sender": ["90dce4861972", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "7a1b524f17d0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.result-null:agents-detected", + "observation": { + "sender": ["02a3532637b4"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-connected.result-null:connected", + "observation": { + "sender": ["02a3532637b4", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "9f0676ba0d67", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.result-null:setup-prompted", + "observation": { + "sender": ["02a3532637b4", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "7a1b524f17d0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-ok-missing:agents-detected", + "observation": { + "sender": ["227c671c491b"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-ok-missing:connected", + "observation": { + "sender": ["227c671c491b", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "9f0676ba0d67", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-ok-missing:setup-prompted", + "observation": { + "sender": ["227c671c491b", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "7a1b524f17d0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-string-error:agents-detected", + "observation": { + "sender": ["fd04a7852302"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-string-error:connected", + "observation": { + "sender": ["fd04a7852302", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "9f0676ba0d67", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-string-error:setup-prompted", + "observation": { + "sender": ["fd04a7852302", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "7a1b524f17d0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-object-error:agents-detected", + "observation": { + "sender": ["162a699815c1"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-object-error:connected", + "observation": { + "sender": ["162a699815c1", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "9f0676ba0d67", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-object-error:setup-prompted", + "observation": { + "sender": ["162a699815c1", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "7a1b524f17d0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused:agents-detected", + "observation": { + "sender": ["c5eeac27af29"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused:connected", + "observation": { + "sender": ["c5eeac27af29", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "9f0676ba0d67", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused:setup-prompted", + "observation": { + "sender": ["c5eeac27af29", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "7a1b524f17d0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused-no-message:agents-detected", + "observation": { + "sender": ["19b6093097ff"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused-no-message:connected", + "observation": { + "sender": ["19b6093097ff", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "9f0676ba0d67", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused-no-message:setup-prompted", + "observation": { + "sender": ["19b6093097ff", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "7a1b524f17d0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.method-not-found:agents-detected", + "observation": { + "sender": ["860046b4ce30"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-connected.method-not-found:connected", + "observation": { + "sender": ["860046b4ce30", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "9f0676ba0d67", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.method-not-found:setup-prompted", + "observation": { + "sender": ["860046b4ce30", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "7a1b524f17d0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection:agents-detected", + "observation": { + "sender": ["07d4c9b0eaf2"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection:connected", + "observation": { + "sender": ["07d4c9b0eaf2", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "9f0676ba0d67", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection:setup-prompted", + "observation": { + "sender": ["07d4c9b0eaf2", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "7a1b524f17d0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection-no-message:agents-detected", + "observation": { + "sender": ["95dee1165f95"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "71225024ccf5", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "9f152ed6e897"] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection-no-message:connected", + "observation": { + "sender": ["95dee1165f95", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "9f0676ba0d67", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection-no-message:setup-prompted", + "observation": { + "sender": ["95dee1165f95", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "7a1b524f17d0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json new file mode 100644 index 00000000000..70861e8c3e5 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json @@ -0,0 +1,975 @@ +{ + "operation": "tasks.workspace-ssh", + "family": "tasks.workspace-ssh", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "39451d3c811068754f91ac243fe1208f4ce742df53261314345d8209ba761e94", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "02800add9d11": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "0ba2cee4b538": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + } + }, + "1712c415bebf": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "decision": "inherit", + "kind": "decision" + } + }, + "1739575ac53e": { + "name": "workspaceSshConnecting", + "value": true + }, + "17e35b25d15d": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": ["codex"] + } + } + }, + "18e6a3ac6471": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "$rpc": "null" + } + }, + "1fcb0efb54e8": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-3", + "ok": false + } + } + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "33cfd55c1890": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "3571f351281f": { + "name": "workspaceDetectedAgentIds", + "value": ["codex"] + }, + "37921d9fdeb7": { + "name": "preflight.detectRemoteAgents#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" + }, + "41b0d115f434": { + "name": "workspaceDetectedAgentIds", + "value": { + "$rpc": "null" + } + }, + "43ead075ce12": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "43fd3e2f4b53": { + "name": "workspaceSshConnecting", + "value": false + }, + "5278c299d57a": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "6139c7d2716a": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of null (reading 'hooks')", + "isRpcDeliveryUnknown": false + } + }, + "70a84db3f870": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-3", + "ok": false + } + } + }, + "71d817ffdd81": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "state": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + } + } + } + }, + "7c7a826833e0": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-3", + "ok": false + } + } + }, + "7c9498659f58": { + "name": "ssh.connect#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}" + }, + "7e1d82e5b5ed": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "decision": "inherit", + "kind": "decision" + }, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "80a4af19f556": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "hooks": { + "scripts": { + "setup": " pnpm install " + } + }, + "setupRunPolicy": "ask", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + } + } + } + }, + "8c3bb432df5b": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "921f72d7827e": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "941b6aeb0d6f": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "a1f755a38636": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "ea709e13f0f0": { + "name": "workspaceAgentOverridden", + "value": false + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ed6189938d78": { + "name": "workspaceAgent", + "value": "claude" + }, + "f0a9f62da106": { + "name": "repo.hooks#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"repo.hooks\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "f21c4f69fe5a": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of undefined (reading 'hooks')", + "isRpcDeliveryUnknown": false + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "f7b1983b91e9": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true + } + } + }, + "fbfdbb919268": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connecting", + "targetId": "ssh-1" + } + }, + "ff43290f6836": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + } + }, + "recording": { + "scenario": "matrix-tasks.workspace-ssh-repo.hooks-1", + "checkpoints": [ + { + "id": "tw-workspace-ssh-connected.prelude:agents-detected", + "observation": { + "sender": ["17e35b25d15d"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "18e6a3ac6471", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "3571f351281f"] + } + }, + { + "id": "tw-workspace-ssh-connected.prelude:connected", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.normal:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "43ead075ce12", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.result-absent:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "f7b1983b91e9"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "f21c4f69fe5a" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.result-null:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "ff43290f6836"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "6139c7d2716a" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-ok-missing:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "5278c299d57a"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "1712c415bebf" + }, + "state": "7e1d82e5b5ed", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-string-error:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "02800add9d11"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "1712c415bebf" + }, + "state": "7e1d82e5b5ed", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-object-error:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "8c3bb432df5b"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "1712c415bebf" + }, + "state": "7e1d82e5b5ed", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "7c7a826833e0"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "32a7c0ae7918" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused-no-message:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "1fcb0efb54e8"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "f3b516f62081" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.method-not-found:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "70a84db3f870"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "b948e8307e81" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "941b6aeb0d6f"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "a947768bc0ed" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection-no-message:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "33cfd55c1890"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "c7584e82c72f" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json new file mode 100644 index 00000000000..3cfc59c03b2 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json @@ -0,0 +1,1421 @@ +{ + "operation": "tasks.workspace-ssh", + "family": "tasks.workspace-ssh", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "5cc1773d06d49d2616da72f2790753322edda6d67e12b5e41116971d34787391", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "09c18a29abf3": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "0ba2cee4b538": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + } + }, + "11181309201b": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "1703db1e81e4": { + "name": "workspaceSshState", + "value": { + "error": "Cannot read properties of undefined (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "1739575ac53e": { + "name": "workspaceSshConnecting", + "value": true + }, + "17e35b25d15d": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": ["codex"] + } + } + }, + "18e6a3ac6471": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "$rpc": "null" + } + }, + "3571f351281f": { + "name": "workspaceDetectedAgentIds", + "value": ["codex"] + }, + "37921d9fdeb7": { + "name": "preflight.detectRemoteAgents#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" + }, + "41b0d115f434": { + "name": "workspaceDetectedAgentIds", + "value": { + "$rpc": "null" + } + }, + "42fb94e15a80": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": "transport failure", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "43ead075ce12": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "43fd3e2f4b53": { + "name": "workspaceSshConnecting", + "value": false + }, + "4a24b4b276fa": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": "outer refused", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "4ca864d39d04": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": "outer refused", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "50b0f369719b": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "5313715e0fbb": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "654de1224bc2": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "671db70f932a": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "6d2db0d7fee0": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": "transport failure", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "71d817ffdd81": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "state": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + } + } + } + }, + "7c9498659f58": { + "name": "ssh.connect#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}" + }, + "80a4af19f556": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "hooks": { + "scripts": { + "setup": " pnpm install " + } + }, + "setupRunPolicy": "ask", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + } + } + } + }, + "86cc01b1e541": { + "name": "workspaceSshState", + "value": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "8a5755fd3ffa": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-2", + "ok": false + } + } + }, + "8dc4620dc1de": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": "Unknown method", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "9164e806ca12": { + "name": "workspaceSshState", + "value": { + "error": "Cannot read properties of null (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "921f72d7827e": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "93dfd351c771": { + "name": "workspaceSshState", + "value": { + "error": "outer refused", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "9eb40e943577": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": "Unknown method", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "a1f755a38636": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "a209f2c7160e": { + "name": "workspaceSshState", + "value": { + "error": "Unknown method", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "aa15b77aca73": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "aad86573b0be": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "b7fa4557dcfa": { + "name": "workspaceSshState", + "value": { + "error": "transport failure", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "c5608f9dd27c": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "c81e3c5c4429": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "cabfead2f0ff": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": "Cannot read properties of null (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "ce4a98c7ed2f": { + "name": "workspaceSshState", + "value": { + "error": "Connection closed", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "ce5554d7557b": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": "Cannot read properties of undefined (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "d86f0ed68c40": { + "agent": "claude", + "connecting": true, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connecting", + "targetId": "ssh-1" + } + }, + "e17430747d93": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": "Cannot read properties of null (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "e29464ad65fd": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-2", + "ok": false + } + } + }, + "e62fd21eb764": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "ea709e13f0f0": { + "name": "workspaceAgentOverridden", + "value": false + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ed6189938d78": { + "name": "workspaceAgent", + "value": "claude" + }, + "f066aa754e25": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": "Cannot read properties of undefined (reading 'state')", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "f0a9f62da106": { + "name": "repo.hooks#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"repo.hooks\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "fbfdbb919268": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connecting", + "targetId": "ssh-1" + } + } + }, + "recording": { + "scenario": "matrix-tasks.workspace-ssh-ssh.connect-1", + "checkpoints": [ + { + "id": "tw-workspace-ssh-connected.prelude:agents-detected", + "observation": { + "sender": ["17e35b25d15d"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "18e6a3ac6471", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "3571f351281f"] + } + }, + { + "id": "tw-workspace-ssh-connected.prelude:cleanup", + "observation": { + "sender": ["17e35b25d15d", "654de1224bc2"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "d86f0ed68c40", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "ce4a98c7ed2f", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.normal:connected", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.normal:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "43ead075ce12", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.result-absent:connected", + "observation": { + "sender": ["17e35b25d15d", "50b0f369719b"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "f066aa754e25", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "1703db1e81e4", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.result-absent:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "50b0f369719b", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "ce5554d7557b", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "1703db1e81e4", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.result-null:connected", + "observation": { + "sender": ["17e35b25d15d", "09c18a29abf3"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "cabfead2f0ff", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "9164e806ca12", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.result-null:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "09c18a29abf3", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "e17430747d93", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "9164e806ca12", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-ok-missing:connected", + "observation": { + "sender": ["17e35b25d15d", "11181309201b"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-ok-missing:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "11181309201b", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "43ead075ce12", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-string-error:connected", + "observation": { + "sender": ["17e35b25d15d", "c81e3c5c4429"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-string-error:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "c81e3c5c4429", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "43ead075ce12", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-object-error:connected", + "observation": { + "sender": ["17e35b25d15d", "e62fd21eb764"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.inner-false-object-error:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "e62fd21eb764", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "43ead075ce12", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused:connected", + "observation": { + "sender": ["17e35b25d15d", "e29464ad65fd"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "4ca864d39d04", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "93dfd351c771", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "e29464ad65fd", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "4a24b4b276fa", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "93dfd351c771", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused-no-message:connected", + "observation": { + "sender": ["17e35b25d15d", "aad86573b0be"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "5313715e0fbb", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "86cc01b1e541", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.outer-refused-no-message:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "aad86573b0be", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "aa15b77aca73", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "86cc01b1e541", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.method-not-found:connected", + "observation": { + "sender": ["17e35b25d15d", "8a5755fd3ffa"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "8dc4620dc1de", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "a209f2c7160e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.method-not-found:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "8a5755fd3ffa", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "9eb40e943577", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "a209f2c7160e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection:connected", + "observation": { + "sender": ["17e35b25d15d", "c5608f9dd27c"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "6d2db0d7fee0", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "b7fa4557dcfa", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "c5608f9dd27c", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "42fb94e15a80", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "b7fa4557dcfa", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection-no-message:connected", + "observation": { + "sender": ["17e35b25d15d", "671db70f932a"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "5313715e0fbb", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "86cc01b1e541", + "43fd3e2f4b53" + ] + } + }, + { + "id": "tw-workspace-ssh-connected.transport-rejection-no-message:setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "671db70f932a", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "aa15b77aca73", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "86cc01b1e541", + "43fd3e2f4b53" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json new file mode 100644 index 00000000000..6368fb4cf12 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json @@ -0,0 +1,652 @@ +{ + "operation": "tasks.worktree-create-retry", + "family": "worktree.create-retry", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "e93a36ef900de27e1a566cdb2389ba4f900a330eadbb476b9bfb1ef05707b352", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0938d32a2ec2": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "12ace8a26229": { + "outcome": { + "error": "outer refused" + } + }, + "151fd59f40cd": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "199931225ca2": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of undefined (reading 'displayName')", + "isRpcDeliveryUnknown": false + } + }, + "240b0b1c72b2": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "" + } + }, + "2588fd63a157": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of undefined (reading 'worktree')", + "isRpcDeliveryUnknown": false + } + }, + "292579caa07d": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "2e78a1dad2ea": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "3f946ad0279c": { + "outcome": "uncreated" + }, + "43a221c63628": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"kestrel\",\"clientMutationId\":\"mutation-1\"}}" + }, + "489c189aebca": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "worktree": { + "displayName": "kestrel", + "id": "repo-1::/w" + } + } + } + } + }, + "6bf7db287168": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "7665e4eb5ce2": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "outer refused" + } + }, + "7d651cae8837": { + "outcome": { + "error": "" + } + }, + "7fbbbeb1902c": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "Unknown method" + } + }, + "8cf7217b02de": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "96a4c62e654d": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "b32227fdb10b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "name": "kestrel", + "worktreeId": "repo-1::/w" + } + }, + "b5447f4dd931": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot read properties of null (reading 'worktree')", + "isRpcDeliveryUnknown": false + } + }, + "b6ebedadd49b": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "c26dcf914d04": { + "outcome": { + "error": "Unknown method" + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "c8b7b7e4da75": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "cf574d8c995b": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "df162b95f465": { + "outcome": { + "name": "kestrel", + "worktreeId": "repo-1::/w" + } + } + }, + "recording": { + "scenario": "matrix-worktree.create-retry-worktree.create-1", + "checkpoints": [ + { + "id": "tw-create-retry-created.normal:created", + "observation": { + "sender": ["489c189aebca"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "b32227fdb10b" + }, + "state": "df162b95f465", + "effects": [] + } + }, + { + "id": "tw-create-retry-created.result-absent:created", + "observation": { + "sender": ["cf574d8c995b"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "2588fd63a157" + }, + "state": "3f946ad0279c", + "effects": [] + } + }, + { + "id": "tw-create-retry-created.result-null:created", + "observation": { + "sender": ["0938d32a2ec2"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "b5447f4dd931" + }, + "state": "3f946ad0279c", + "effects": [] + } + }, + { + "id": "tw-create-retry-created.inner-ok-missing:created", + "observation": { + "sender": ["6bf7db287168"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "199931225ca2" + }, + "state": "3f946ad0279c", + "effects": [] + } + }, + { + "id": "tw-create-retry-created.inner-false-string-error:created", + "observation": { + "sender": ["96a4c62e654d"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "199931225ca2" + }, + "state": "3f946ad0279c", + "effects": [] + } + }, + { + "id": "tw-create-retry-created.inner-false-object-error:created", + "observation": { + "sender": ["2e78a1dad2ea"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "199931225ca2" + }, + "state": "3f946ad0279c", + "effects": [] + } + }, + { + "id": "tw-create-retry-created.outer-refused:created", + "observation": { + "sender": ["c8b7b7e4da75"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "7665e4eb5ce2" + }, + "state": "12ace8a26229", + "effects": [] + } + }, + { + "id": "tw-create-retry-created.outer-refused-no-message:created", + "observation": { + "sender": ["b6ebedadd49b"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "240b0b1c72b2" + }, + "state": "7d651cae8837", + "effects": [] + } + }, + { + "id": "tw-create-retry-created.method-not-found:created", + "observation": { + "sender": ["151fd59f40cd"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "7fbbbeb1902c" + }, + "state": "c26dcf914d04", + "effects": [] + } + }, + { + "id": "tw-create-retry-created.transport-rejection:created", + "observation": { + "sender": ["292579caa07d"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "a947768bc0ed" + }, + "state": "3f946ad0279c", + "effects": [] + } + }, + { + "id": "tw-create-retry-created.transport-rejection-no-message:created", + "observation": { + "sender": ["8cf7217b02de"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "c7584e82c72f" + }, + "state": "3f946ad0279c", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json new file mode 100644 index 00000000000..39cec3ffb81 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json @@ -0,0 +1,738 @@ +{ + "operation": "tasks.composer-hosted-base", + "family": "worktree.hosted-base", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "a84f8a5acfd428eb77b5c02a3de0fa8b780c666db31bbe574ecf76cdf84adeb2", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "08ecbab921e6": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "0e24d2a37a0d": { + "name": "worktree.resolvePrBase#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.resolvePrBase\",\"params\":{\"repo\":\"id:repo-1\",\"prNumber\":12,\"headRefName\":\"feature\"}}" + }, + "156f5e61efd3": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "17ae65496a72": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-2", + "ok": false + } + } + }, + "201cea1f9864": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-2", + "ok": false + } + } + }, + "22f024eeb07c": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "236529aa012d": { + "mrBase": "unresolved", + "prBase": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + }, + "2aaea8ee523e": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused", + "isRpcDeliveryUnknown": false + } + }, + "2c7f810cc819": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "[object Object]", + "isRpcDeliveryUnknown": false + } + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "336e99424dd0": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "382c27f806f2": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "3cca8119f144": { + "mrBase": { + "baseBranch": "develop" + }, + "prBase": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + }, + "4febe923ceea": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + } + } + }, + "5428de0f5130": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + }, + "69afcaf1cb72": { + "name": "worktree.resolveMrBase#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.resolveMrBase\",\"params\":{\"repo\":\"id:repo-1\",\"mrIid\":7,\"sourceBranch\":\"feature\"}}" + }, + "7214459608bf": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot use 'in' operator to search for 'error' in null", + "isRpcDeliveryUnknown": false + } + }, + "93bb7cfeae89": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "96b186d42430": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "aad8e7ddeea2": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "baseBranch": "develop" + } + } + } + }, + "ae5862eb7a20": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot use 'in' operator to search for 'error' in undefined", + "isRpcDeliveryUnknown": false + } + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "bd721565327b": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "d05b2d417b9c": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "inner refused", + "isRpcDeliveryUnknown": false + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "fd552ecb03da": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "baseBranch": "develop" + } + } + }, + "recording": { + "scenario": "matrix-worktree.hosted-base-worktree.resolvemrbase-1", + "checkpoints": [ + { + "id": "tw-hosted-base-resolved.prelude:pr-base-resolved", + "observation": { + "sender": ["4febe923ceea"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "5428de0f5130" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.normal:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "fd552ecb03da" + }, + "state": "3cca8119f144", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.result-absent:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "93bb7cfeae89"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "ae5862eb7a20" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.result-null:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "336e99424dd0"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "7214459608bf" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.inner-ok-missing:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "08ecbab921e6"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "2aaea8ee523e" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.inner-false-string-error:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "96b186d42430"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "d05b2d417b9c" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.inner-false-object-error:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "156f5e61efd3"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "2c7f810cc819" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.outer-refused:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "201cea1f9864"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "32a7c0ae7918" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.outer-refused-no-message:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "382c27f806f2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "f3b516f62081" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.method-not-found:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "17ae65496a72"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "b948e8307e81" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.transport-rejection:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "bd721565327b"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "a947768bc0ed" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.transport-rejection-no-message:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "22f024eeb07c"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "c7584e82c72f" + }, + "state": "236529aa012d", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json new file mode 100644 index 00000000000..ad114ee98a3 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json @@ -0,0 +1,868 @@ +{ + "operation": "tasks.composer-hosted-base", + "family": "worktree.hosted-base", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "45783a7cbb44b04dbbd6bfd6735799bb4c75e503f43f1821cf8640d11f7464ad", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0ace0141301c": { + "mrBase": { + "baseBranch": "develop" + }, + "prBase": "unresolved" + }, + "0e24d2a37a0d": { + "name": "worktree.resolvePrBase#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.resolvePrBase\",\"params\":{\"repo\":\"id:repo-1\",\"prNumber\":12,\"headRefName\":\"feature\"}}" + }, + "236529aa012d": { + "mrBase": "unresolved", + "prBase": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + }, + "28b232b6369b": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "2aaea8ee523e": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "refused", + "isRpcDeliveryUnknown": false + } + }, + "2c7f810cc819": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "[object Object]", + "isRpcDeliveryUnknown": false + } + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "388eebbe7dca": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "3cca8119f144": { + "mrBase": { + "baseBranch": "develop" + }, + "prBase": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + }, + "4febe923ceea": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + } + } + }, + "5428de0f5130": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + }, + "5ce5558cd2f1": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "60f898896e1a": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "62d33be71d4d": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "658dfb6d27f2": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "69afcaf1cb72": { + "name": "worktree.resolveMrBase#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.resolveMrBase\",\"params\":{\"repo\":\"id:repo-1\",\"mrIid\":7,\"sourceBranch\":\"feature\"}}" + }, + "7214459608bf": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot use 'in' operator to search for 'error' in null", + "isRpcDeliveryUnknown": false + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "aad8e7ddeea2": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "baseBranch": "develop" + } + } + } + }, + "ae5862eb7a20": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "TypeError", + "message": "Cannot use 'in' operator to search for 'error' in undefined", + "isRpcDeliveryUnknown": false + } + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "c57e06c96492": { + "mrBase": "unresolved", + "prBase": "unresolved" + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "d05b2d417b9c": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "inner refused", + "isRpcDeliveryUnknown": false + } + }, + "d778e31ef5f7": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "e3d0229e3cdb": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "f19c03489128": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + }, + "f4bbce06e9b6": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "fd552ecb03da": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "baseBranch": "develop" + } + } + }, + "recording": { + "scenario": "matrix-worktree.hosted-base-worktree.resolveprbase-1", + "checkpoints": [ + { + "id": "tw-hosted-base-resolved.normal:pr-base-resolved", + "observation": { + "sender": ["4febe923ceea"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "5428de0f5130" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.normal:mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "fd552ecb03da" + }, + "state": "3cca8119f144", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.result-absent:pr-base-resolved", + "observation": { + "sender": ["60f898896e1a"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "ae5862eb7a20" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.result-absent:mr-base-resolved", + "observation": { + "sender": ["60f898896e1a", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "ae5862eb7a20", + "mr": "fd552ecb03da" + }, + "state": "0ace0141301c", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.result-null:pr-base-resolved", + "observation": { + "sender": ["f4bbce06e9b6"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "7214459608bf" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.result-null:mr-base-resolved", + "observation": { + "sender": ["f4bbce06e9b6", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "7214459608bf", + "mr": "fd552ecb03da" + }, + "state": "0ace0141301c", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.inner-ok-missing:pr-base-resolved", + "observation": { + "sender": ["658dfb6d27f2"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "2aaea8ee523e" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.inner-ok-missing:mr-base-resolved", + "observation": { + "sender": ["658dfb6d27f2", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "2aaea8ee523e", + "mr": "fd552ecb03da" + }, + "state": "0ace0141301c", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.inner-false-string-error:pr-base-resolved", + "observation": { + "sender": ["62d33be71d4d"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "d05b2d417b9c" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.inner-false-string-error:mr-base-resolved", + "observation": { + "sender": ["62d33be71d4d", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "d05b2d417b9c", + "mr": "fd552ecb03da" + }, + "state": "0ace0141301c", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.inner-false-object-error:pr-base-resolved", + "observation": { + "sender": ["5ce5558cd2f1"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "2c7f810cc819" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.inner-false-object-error:mr-base-resolved", + "observation": { + "sender": ["5ce5558cd2f1", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "2c7f810cc819", + "mr": "fd552ecb03da" + }, + "state": "0ace0141301c", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.outer-refused:pr-base-resolved", + "observation": { + "sender": ["f19c03489128"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "32a7c0ae7918" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.outer-refused:mr-base-resolved", + "observation": { + "sender": ["f19c03489128", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "32a7c0ae7918", + "mr": "fd552ecb03da" + }, + "state": "0ace0141301c", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.outer-refused-no-message:pr-base-resolved", + "observation": { + "sender": ["e3d0229e3cdb"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "f3b516f62081" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.outer-refused-no-message:mr-base-resolved", + "observation": { + "sender": ["e3d0229e3cdb", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "f3b516f62081", + "mr": "fd552ecb03da" + }, + "state": "0ace0141301c", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.method-not-found:pr-base-resolved", + "observation": { + "sender": ["28b232b6369b"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "b948e8307e81" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.method-not-found:mr-base-resolved", + "observation": { + "sender": ["28b232b6369b", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "b948e8307e81", + "mr": "fd552ecb03da" + }, + "state": "0ace0141301c", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.transport-rejection:pr-base-resolved", + "observation": { + "sender": ["d778e31ef5f7"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "a947768bc0ed" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.transport-rejection:mr-base-resolved", + "observation": { + "sender": ["d778e31ef5f7", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "a947768bc0ed", + "mr": "fd552ecb03da" + }, + "state": "0ace0141301c", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.transport-rejection-no-message:pr-base-resolved", + "observation": { + "sender": ["388eebbe7dca"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "c7584e82c72f" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "tw-hosted-base-resolved.transport-rejection-no-message:mr-base-resolved", + "observation": { + "sender": ["388eebbe7dca", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "c7584e82c72f", + "mr": "fd552ecb03da" + }, + "state": "0ace0141301c", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json index 8ab2bcc788a..b67232b5be4 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json @@ -3,9 +3,9 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "2fc093ec505bfac04a4ff0adab991baeba985253486dbe9e3ec9884b8d5f0920", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json new file mode 100644 index 00000000000..3f93f9c546d --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json @@ -0,0 +1,570 @@ +{ + "operation": "tasks.worktree-capabilities", + "family": "worktree.runtime-capabilities", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "f70c6b1753377b5a502bf7d1e69dc95617f24c42137471320eb393567efbe735", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "16cd464bf664": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "1e5b32902af7": { + "name": "status.get#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "2698c9770ad3": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "4451bb95a76e": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "5242fad3532f": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "capabilities": ["mobile.tasks.v1", "worktree.create-idempotency.v1"], + "platform": "linux", + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + } + } + } + } + }, + "62aaf19f0b16": { + "capabilities": { + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + } + } + }, + "7d3dd7f9381b": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "86f7fa8089fe": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": false, + "worktreeCreateIdempotency": false + } + }, + "88200d49083c": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "89236e432861": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "944bf432f199": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "9cdf3c107e7b": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "b33d34bddc4e": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + } + } + }, + "c71b2f8a6993": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "de87f6266897": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "f80e92134eb1": { + "capabilities": { + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": false, + "worktreeCreateIdempotency": false + } + } + }, + "recording": { + "scenario": "matrix-worktree.runtime-capabilities-status.get-1", + "checkpoints": [ + { + "id": "tw-capabilities-advertised.normal:probed", + "observation": { + "sender": ["5242fad3532f"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "b33d34bddc4e" + }, + "state": "62aaf19f0b16", + "effects": [] + } + }, + { + "id": "tw-capabilities-advertised.result-absent:probed", + "observation": { + "sender": ["7d3dd7f9381b"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "86f7fa8089fe" + }, + "state": "f80e92134eb1", + "effects": [] + } + }, + { + "id": "tw-capabilities-advertised.result-null:probed", + "observation": { + "sender": ["88200d49083c"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "86f7fa8089fe" + }, + "state": "f80e92134eb1", + "effects": [] + } + }, + { + "id": "tw-capabilities-advertised.inner-ok-missing:probed", + "observation": { + "sender": ["4451bb95a76e"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "86f7fa8089fe" + }, + "state": "f80e92134eb1", + "effects": [] + } + }, + { + "id": "tw-capabilities-advertised.inner-false-string-error:probed", + "observation": { + "sender": ["944bf432f199"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "86f7fa8089fe" + }, + "state": "f80e92134eb1", + "effects": [] + } + }, + { + "id": "tw-capabilities-advertised.inner-false-object-error:probed", + "observation": { + "sender": ["89236e432861"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "86f7fa8089fe" + }, + "state": "f80e92134eb1", + "effects": [] + } + }, + { + "id": "tw-capabilities-advertised.outer-refused:probed", + "observation": { + "sender": ["16cd464bf664"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "86f7fa8089fe" + }, + "state": "f80e92134eb1", + "effects": [] + } + }, + { + "id": "tw-capabilities-advertised.outer-refused-no-message:probed", + "observation": { + "sender": ["9cdf3c107e7b"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "86f7fa8089fe" + }, + "state": "f80e92134eb1", + "effects": [] + } + }, + { + "id": "tw-capabilities-advertised.method-not-found:probed", + "observation": { + "sender": ["c71b2f8a6993"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "86f7fa8089fe" + }, + "state": "f80e92134eb1", + "effects": [] + } + }, + { + "id": "tw-capabilities-advertised.transport-rejection:probed", + "observation": { + "sender": ["de87f6266897"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "86f7fa8089fe" + }, + "state": "f80e92134eb1", + "effects": [] + } + }, + { + "id": "tw-capabilities-advertised.transport-rejection-no-message:probed", + "observation": { + "sender": ["2698c9770ad3"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "86f7fa8089fe" + }, + "state": "f80e92134eb1", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json new file mode 100644 index 00000000000..51dc69ecef9 --- /dev/null +++ b/mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json @@ -0,0 +1,673 @@ +{ + "operation": "tasks.setup-hook-trust", + "family": "worktree.setup-hook-trust", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "8487fd14ed779708415b264e2b80b26b0d5094e379a04f4f32c2cd75ec469182", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0f68ccbfb8e9": { + "name": "ui.set#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"trustedOrcaHooks\":{\"repo-1\":{\"setup\":{\"contentHash\":\"hash-1\",\"approvedAt\":1767225600000}}}}}" + }, + "229c35d1a4ba": { + "trust": "unapproved" + }, + "255cdc090b8a": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "2905cce95e1c": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, + "ok": false + } + } + } + }, + "29fc0c5de3b0": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "32af950a57cc": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "6f009f61d89f": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "ok": true + } + } + } + }, + "9deb505f7915": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "a406b068aeca": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + }, + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + }, + "abd752b10f76": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "ac7d2d4aa85c": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "b2aa9ff12623": { + "trust": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "d1113bd291a2": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "e3e6506e1ed0": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "e9c010ad58d3": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + } + }, + "recording": { + "scenario": "matrix-worktree.setup-hook-trust-ui.set-1", + "checkpoints": [ + { + "id": "tw-setup-hook-trust-approved.normal:approved", + "observation": { + "sender": ["6f009f61d89f"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "a406b068aeca" + }, + "state": "b2aa9ff12623", + "effects": [] + } + }, + { + "id": "tw-setup-hook-trust-approved.result-absent:approved", + "observation": { + "sender": ["e9c010ad58d3"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "a406b068aeca" + }, + "state": "b2aa9ff12623", + "effects": [] + } + }, + { + "id": "tw-setup-hook-trust-approved.result-null:approved", + "observation": { + "sender": ["9deb505f7915"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "a406b068aeca" + }, + "state": "b2aa9ff12623", + "effects": [] + } + }, + { + "id": "tw-setup-hook-trust-approved.inner-ok-missing:approved", + "observation": { + "sender": ["ac7d2d4aa85c"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "a406b068aeca" + }, + "state": "b2aa9ff12623", + "effects": [] + } + }, + { + "id": "tw-setup-hook-trust-approved.inner-false-string-error:approved", + "observation": { + "sender": ["d1113bd291a2"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "a406b068aeca" + }, + "state": "b2aa9ff12623", + "effects": [] + } + }, + { + "id": "tw-setup-hook-trust-approved.inner-false-object-error:approved", + "observation": { + "sender": ["2905cce95e1c"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "a406b068aeca" + }, + "state": "b2aa9ff12623", + "effects": [] + } + }, + { + "id": "tw-setup-hook-trust-approved.outer-refused:approved", + "observation": { + "sender": ["e3e6506e1ed0"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "32a7c0ae7918" + }, + "state": "229c35d1a4ba", + "effects": [] + } + }, + { + "id": "tw-setup-hook-trust-approved.outer-refused-no-message:approved", + "observation": { + "sender": ["32af950a57cc"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "f3b516f62081" + }, + "state": "229c35d1a4ba", + "effects": [] + } + }, + { + "id": "tw-setup-hook-trust-approved.method-not-found:approved", + "observation": { + "sender": ["255cdc090b8a"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "b948e8307e81" + }, + "state": "229c35d1a4ba", + "effects": [] + } + }, + { + "id": "tw-setup-hook-trust-approved.transport-rejection:approved", + "observation": { + "sender": ["29fc0c5de3b0"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "a947768bc0ed" + }, + "state": "229c35d1a4ba", + "effects": [] + } + }, + { + "id": "tw-setup-hook-trust-approved.transport-rejection-no-message:approved", + "observation": { + "sender": ["abd752b10f76"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "c7584e82c72f" + }, + "state": "229c35d1a4ba", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json index 5d21ce568a0..84448b023a8 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "819fa73c7700b4d526da91c37558a6498008d745d1debcc26e6bb757550ebf99", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json index 68a52959edf..4a8275e7f2c 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "500396d72abd2f73d11ef066bca3f88798c8cbdaef09fa7c1c8d1fbaf0b3b85a", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json index 5a74ca74542..4302f0e7730 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "daf68df8840ea6872521d823cc17e1e5de3f3a74a8855465fcf40cc276e9c2ce", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json index 441f1284661..454f9e80ae4 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "61e36caf6b3bb01c3ad0db282b7f0fbc0f300d40184f9cf3d7e4e3a3194a4f2a", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-default.json b/mobile/rpc-foundation/goldens/sc-base-ref-default.json index efc8a33b474..ebfc023c308 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-default.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-default.json @@ -3,9 +3,9 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "374129def6baa0e06b808c067831820966638d79d7a782e96c1f2f891cc9dc86", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json index 8b451a3c47b..a8691ccbd5a 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json @@ -3,9 +3,9 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "97a8b8f5b9a7c7467745666becee07f5dfc57fb283d4e80dcbe7941509177598", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json index 6f61749a0ff..16a2202e4b1 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json @@ -3,9 +3,9 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "25a4762d735dfb4979e6ef31b9fdb380941a824a54b45b3d08ddb2cde25c2eb7", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json index e134ff8d55d..ad01210efd2 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json @@ -3,9 +3,9 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "f3b193f93f6c9de41d11e706ecbd99648eb2ed41ccb7c66cdb80c934e780ed7c", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json index 5efc0525535..b6666d9ec9c 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json @@ -3,9 +3,9 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "013db25622b180a8333bb1ef27c22a5b1f8e04148201201e5cc3413a10640781", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json index 84768ce1373..d70e7682eca 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json @@ -3,9 +3,9 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "15bf6c17f4b524dfbf5373b2eeed61ee2e659421cf8b6e3cff6c0378c7692cc1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json index a8abfe60296..31319ebc375 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json @@ -3,9 +3,9 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "540f05d84d1cbffd566af933c547838c75500bb5d708e8558c21fe8131d724e3", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-create-existing-review.json b/mobile/rpc-foundation/goldens/sc-create-existing-review.json index e51f2ee4e8b..181f8a1b23d 100644 --- a/mobile/rpc-foundation/goldens/sc-create-existing-review.json +++ b/mobile/rpc-foundation/goldens/sc-create-existing-review.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "7d6099248aa6a2ef19f2e169ff917af794649d9d64d139aa9ffeea6a41355ddc", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json index 91ac688ebcb..f05449a2b3b 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "447f9b3d697dbfe21cb7fb6e12d1bf5fa94b023b7e1697bdc2dc82ce7072183f", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json index 818cf613211..a5778ad675a 100644 --- a/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json +++ b/mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "2deb0435ef63a3e0102e28f2f3f331039486d193d1e1ffdfb53ad86d3ff039f0", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json index 32d59893c05..d21d3a1a61f 100644 --- a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json +++ b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "0f86b3e6059c48cd327c55df2452a9bc6ea85584ffbeacafad496f600c20e06f", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json index d810ad5a8c5..d59ebbd5baf 100644 --- a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "5008e69a8e396b1deccd98712d92650a630f971cd76a02862a461afb8617b8a4", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json index 337f070d679..bdcca8a6bc5 100644 --- a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json @@ -3,9 +3,9 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "ea71081982a101d0f8624707b59c99de9981e9b1d1bafa25c66d575e3f876ff4", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json index 5de6a24a2af..391a867ba41 100644 --- a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json +++ b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json @@ -3,9 +3,9 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "f6a1595073abe11b33973e8865900a1d849f44221961da5c12ea13aa696f6490", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-history-loaded.json b/mobile/rpc-foundation/goldens/sc-history-loaded.json index 848a5c1acdc..1b56488029a 100644 --- a/mobile/rpc-foundation/goldens/sc-history-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-history-loaded.json @@ -3,9 +3,9 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "83f61085a91458bad529905ecc6fe240c598cddfe44a56dd497b8aed9fb8a7e5", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json index c1a63194a95..f06f0924835 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json @@ -3,9 +3,9 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "42b0304b2fdce08b7ff52ec979dd9f199f368e5e4ef0b5370acc417909b592b3", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-read.json b/mobile/rpc-foundation/goldens/sc-pr-link-read.json index 908b8db23be..9a66cde21df 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-read.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-read.json @@ -3,9 +3,9 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "4520bd54a55eabfe6ec64a4b2f824f095f98b2fffd1bf22fe4f9ec7f63cbfa3f", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-set.json b/mobile/rpc-foundation/goldens/sc-pr-link-set.json index b161e5d105f..a21493d5720 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-set.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-set.json @@ -3,9 +3,9 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "b65bad4f8c0ae0b686f6c3db93bd43ffa072ae426f978f1a86ad8008fb24fa24", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json index 695d5f054b1..0d760a946e1 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json @@ -3,9 +3,9 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "9de96287a4dfa6cf5c9a8b683cc696fdc2cd387f86f231e22ee3f100a2e778e3", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json index 2c6c73ee674..5d49f9b9773 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json @@ -3,9 +3,9 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "adbdfcc3895cc04d830900de518e689c9e63f6f75569127e1fde24488658e8a0", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json index d1358b6562e..bff1bf885a6 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json @@ -3,9 +3,9 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "ec2847b4af357d8564d8e0a9a1072713c1afd7ff86ba69e9c83c056a6841ee39", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json index dfb64a312d6..7d369247287 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json @@ -3,9 +3,9 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "6cf6ebd20adc4cc76a12d3424863ee9db2b24f36593664a1f4e0e05de9a53d39", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json index ed73ee99553..e656c95ac90 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json @@ -3,9 +3,9 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "e6e8197a541cd73e5811a1f28b0dbfc414a4d34c1ae6929fc1bbae1213820674", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json index 1684dea4b6c..79a72c580a3 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json @@ -3,9 +3,9 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "523d1ee21e3871a4dffc32f48d2e28c31ecea48cbf3f842acffc8355be06b14b", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json index 16850663572..b838e980702 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json @@ -3,9 +3,9 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "00b26cb279b0a934df98d93ba98a4a0c79e302c7690c582e778dc0156ab4f235", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json index 0ef7c028dea..b7f54a2ead1 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json @@ -3,9 +3,9 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "fcd61f1ef46c42889827a87876239534b851c425f8ef7a9405ea95b8d07d2363", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json index 8fbaaf8af5d..7eac89525b2 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json @@ -3,9 +3,9 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "db1fb2a584cd806028b9be861283a63aa4836c83f61558f3d518ddbb7a59498d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json index f739f278bf4..9e6b8323541 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json @@ -3,9 +3,9 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "dba1676583dc832ef059285a6bd4c3eefe6be0230e42100cb9d7a125755d136b", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json index 2f5b022f361..e7d9722de05 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json @@ -3,9 +3,9 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "de079de9cc21bfb40da6e1431273b10c3e2a5b5402b91b2b8a85c8d7ac41bc97", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-review-commit.json b/mobile/rpc-foundation/goldens/sc-review-commit.json index c3092244e76..c4c3f82463c 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit.json @@ -3,9 +3,9 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a29d518b2d075e8e4811404e0fcbf8948fbfe53a3aefbf0948cb2f2e622e8cbb", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json index 0c03aa7a953..2bbfe9a168d 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json @@ -3,9 +3,9 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "37ba7780ca9525ab313c0a9c781ce5bb26344e63af9272c32ae889621f383b2b", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json index 27735b6ec4f..c29943be50c 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json @@ -3,9 +3,9 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "93193acd57d6f00abc8e6c22ec3a8f1ca6ce7c5808d906d0aa7c11e41dab4635", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/schedules-b3.json b/mobile/rpc-foundation/goldens/schedules-b3.json index 8744ce77f9d..220b4ee8813 100644 --- a/mobile/rpc-foundation/goldens/schedules-b3.json +++ b/mobile/rpc-foundation/goldens/schedules-b3.json @@ -3,9 +3,9 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "b59fb599dd3a5fbc79bb8602dcec4b1c51a392c662efab7efc8324fc718ce8de", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json index ea6b3307985..5196a2f12ae 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "fbd311a377672a9335521c30734880eea1b04bab0aff367854c1deebcf66b105", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json index 5fade18c847..6a5c107d83e 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "2726d71130f623e3ad02c168c13269979ca6f84703bf1c5aaf36bd4432dfb516", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json index b211647b7ad..cc21cab7281 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "664eba1468e229f9ac2dced262e7ad896ead01688dff4c570f397c3f8594efd7", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json index 6ba9db9e317..8c7eafde756 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "7bfba3fae1dc33acf40e8a955bbfccf28580b3daee3270dec6a15e6cefd45a84", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json index 46ae74ee9a7..0aae40845dd 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "02e3ca10296704b5478185e9d3dc0136596a2ee57580d7f9268672568dab4cd4", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json index 1d576fb6c54..9f3f14035d7 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "eb980fc027ca0200212ba6ad3bf9a1ab3460936a7bb4b04353a9462eecd287a1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json index 339e6c67c53..7da556a808a 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "4b4b4a8d1acaaec1c8dde0233dc49a696ffe53466578477efcbcdb7263dbd617", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json index 47ff0ace506..711d1e294f9 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json @@ -3,9 +3,9 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "cac4980465661fba372e187699741123a4edeb9270125a0a1cad7bbb6a6adebd", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json index f0ea7524e05..1662fe79f25 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json @@ -3,9 +3,9 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "f8ebd2348373b2b39c167735e0c418dfe868511fb5306ecba90cb6f2a905b95e", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json index eb7b6d59568..85a8bc0de4b 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json @@ -3,9 +3,9 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "f7e63144421a689f05cc50ad87ec901a9eaeb3163165656887be12a5f2753005", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-home-coalesced.json b/mobile/rpc-foundation/goldens/settings-home-coalesced.json index f75d79cd560..3edbbc93f40 100644 --- a/mobile/rpc-foundation/goldens/settings-home-coalesced.json +++ b/mobile/rpc-foundation/goldens/settings-home-coalesced.json @@ -3,9 +3,9 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "d8d6f738ee11d84d6e9e546624f4babcb42476432f6bddbc74e8519d9ca18370", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json index b46ae96ec3e..f198b0ace0a 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a32b2fc99e830c58460e6f7c857aed0048738a55501e508eb236604680b9c235", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json index 873318de0fc..35edc70f458 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json @@ -3,9 +3,9 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "78c48025c4af6cc0f1f136448c0ede9f76b7485d7b33b1356d11dec017bd9053", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json index c0eed18e92b..5cbb59b2775 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json @@ -3,9 +3,9 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "c22f33a0ed28622d4d53ae31e934056f87d2c10e8dc4475831ae1ee5fd3a9b8b", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json index b01cf2c4ea4..25d6642a6e0 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json @@ -3,9 +3,9 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "4f16b43dddfb9257828342b0297317868df24b03fd98a89c66f5cd1897829d73", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json index 635d3093dd9..f7e57aaf56e 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "b6fb40be3bb92d7d9f1a79d99dee077cf95097077917679dc99702f912241fa4", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json index af4bbc7873a..3e3729245bf 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "31f8a348322551738b14207b3477bae492d48d45c5d51be4d97ffaca2fe2b6e1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json index 53721dfee13..0f79d30fa41 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json @@ -3,9 +3,9 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "1347663aba0ada1eee8e88fac306757dc0d29fe21f0d062ac2d3968a30a2f214", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json index 1dcccf8c919..b7bfa5ab580 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json +++ b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "adebd553e2648278d719a1d7299cb36683fce714682a1ab7b49d4c9027eea34e", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json index 1a996c52104..1dcaccdec53 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "b45eba2007e8e2668f524cd7503b8a711eba67816c9c35af5c3725a1afe32d8d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json index 06c148924b8..27bd3159e29 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "b1c0b957b828c32e7ec388ec6668273fe84bbe5d11d8286b9a246fa92395a26e", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json index cc4100645ad..20475c72af4 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "867b6905c8a533ddd1c7c8174bf4aadd5fd725cc72bdddbcb2ea8af26e219078", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json index 4b6030d3e80..b889cc0db12 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "d8a00a72849f1ed254c3b35ebcc330dd1bb15b189f006bd1517853a19e53de6c", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json index ed46e819bda..e3f86b71eb6 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json @@ -3,9 +3,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "741db13a84dbcec2e97e80605d742e69558954657c72f8450f3f8bc177dd01b6", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json index 40c1299216d..3d29c1d46ea 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "7a8d0a5305aafea56733c229989b6e825fe9b8a681f48f6cef405350304520b6", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json index c2134a9da51..6330d39ab1d 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json @@ -3,9 +3,9 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a11756ecaf7c2d3955b9512aa7479ca55d810341f1492f472985abb538e140e8", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json index 06fcf005535..1899a1fc06d 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json @@ -3,9 +3,9 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "def0640be601a8013f9537f161b60d8c14ac9551931e5ee4d4cc2acd3c2baf2a", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json index 27398aa2494..00ee88aa448 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json @@ -3,9 +3,9 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "17c31d0c2b7ae5322fd59bafcfa1d2779ae9eff841e12c0cf16b27e454b49f13", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json index 0487f1b4564..01f7a6dedaf 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "c08cce5d1f71761dbf504863b736e5546abb42b9ff4ab8ced65c7c42e3d66c0e", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json index 73654e37ced..fb613fe5bdc 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "b23c3076081901c89e8a8fb8d20028e03f030db040c9cd793b6f2c7cd49d8f25", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json index c22817884ae..9d12b5ec505 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a510ff7505cddbd6dad3c7e5a2dcde206a5dab1940901511d72c97aca576a6f1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json index 38b2cd42359..643f2b34501 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json @@ -3,9 +3,9 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "d4a2fef3aefb78bdb4aed94fda982124f24a3af3832227654d324735f44aaeeb", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json new file mode 100644 index 00000000000..6b528d49409 --- /dev/null +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json @@ -0,0 +1,241 @@ +{ + "operation": "settings.task-workspace-create", + "family": "settings.task-workspace-create", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "662c3e04e31bce5757f09f91e3e3739fb9d57767b7443be4dc936705b64b1432", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "067cef118d9f": { + "name": "runtimeTaskSettings", + "value": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "090c88478661": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "0f72e7ee78c9": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "createdWithAgent": "claude", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "name": "orc-1", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://linear.app/orca/issue/ORC-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "worktree": { + "displayName": "ORC-1 Recorded issue", + "id": "wt-1" + } + } + } + } + }, + "180125f5d1a6": { + "name": "workspaceCreateDraft", + "value": { + "$rpc": "null" + } + }, + "2473f12c7cdd": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + } + } + } + }, + "33e3b949d4c5": { + "creating": { + "$rpc": "null" + }, + "error": "", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "6eb4e79ad99a": { + "name": "setupPrompt", + "value": { + "$rpc": "null" + } + }, + "730f92993963": { + "name": "creatingKey", + "value": "linear:1" + }, + "7abdfe20af50": { + "creating": "linear:1", + "error": "", + "settings": { + "disabledTuiAgents": ["claude"] + } + }, + "7ddcb1852b39": { + "name": "settings.get#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "7ee99993a895": { + "name": "navigation", + "value": "/h/host-1/session/wt-1?name=ORC-1+Recorded+issue&created=1" + }, + "82cd71d524c8": { + "name": "error", + "value": "" + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "ac9996319e05": { + "name": "actionItem", + "value": { + "$rpc": "null" + } + }, + "baa74a0ec378": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"orc-1\",\"displayName\":\"ORC-1 Recorded issue\",\"displayNameKind\":\"generated\",\"linkedLinearIssue\":\"ORC-1\",\"setupDecision\":\"inherit\",\"activate\":true,\"startupDraft\":\"https://linear.app/orca/issue/ORC-1\",\"createdWithAgent\":\"claude\"}}" + }, + "c9cb32059b8d": { + "name": "creatingKey", + "value": { + "$rpc": "null" + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + } + }, + "recording": { + "scenario": "settings-task-workspace-create-linear", + "checkpoints": [ + { + "id": "settings-pending", + "observation": { + "sender": ["090c88478661"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "9270aeb7d9c6" + }, + "state": "7abdfe20af50", + "effects": ["730f92993963", "82cd71d524c8"] + } + }, + { + "id": "created", + "observation": { + "sender": ["2473f12c7cdd", "0f72e7ee78c9"], + "payloads": ["7ddcb1852b39", "baa74a0ec378"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "33e3b949d4c5", + "effects": [ + "730f92993963", + "82cd71d524c8", + "067cef118d9f", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "7ee99993a895", + "c9cb32059b8d" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json new file mode 100644 index 00000000000..ebdfc322c9a --- /dev/null +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json @@ -0,0 +1,335 @@ +{ + "operation": "settings.task-workspace-create", + "family": "settings.task-workspace-create", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "8ae9e1dbb32d404eac9e01f71dacf1c37497030220a8e988c0093bb7ed2d159b", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "067cef118d9f": { + "name": "runtimeTaskSettings", + "value": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "090c88478661": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "180125f5d1a6": { + "name": "workspaceCreateDraft", + "value": { + "$rpc": "null" + } + }, + "2473f12c7cdd": { + "name": "settings.get#1", + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + } + } + } + }, + "33e3b949d4c5": { + "creating": { + "$rpc": "null" + }, + "error": "", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "3dc266b1bda1": { + "creating": "github:7", + "error": "", + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": [] + } + }, + "52051fd3214e": { + "creating": "github:7", + "error": "", + "settings": { + "disabledTuiAgents": ["claude"] + } + }, + "6eb4e79ad99a": { + "name": "setupPrompt", + "value": { + "$rpc": "null" + } + }, + "7ddcb1852b39": { + "name": "settings.get#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "82cd71d524c8": { + "name": "error", + "value": "" + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "9e9f36142bbd": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "baseBranch": "main", + "createdWithAgent": "claude", + "displayName": "Recorded pull request", + "displayNameKind": "generated", + "linkedPR": 7, + "name": "pr-7", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://github.com/o/r/pull/7" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "warning": "shallow clone", + "worktree": { + "id": "wt-2" + } + } + } + } + }, + "a49b109c46d4": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "activate": true, + "baseBranch": "main", + "createdWithAgent": "claude", + "displayName": "Recorded pull request", + "displayNameKind": "generated", + "linkedPR": 7, + "name": "pr-7", + "repo": "id:repo-1", + "setupDecision": "inherit", + "startupDraft": "https://github.com/o/r/pull/7" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "ac9996319e05": { + "name": "actionItem", + "value": { + "$rpc": "null" + } + }, + "b66f6d1958b9": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"pr-7\",\"displayName\":\"Recorded pull request\",\"displayNameKind\":\"generated\",\"setupDecision\":\"inherit\",\"activate\":true,\"startupDraft\":\"https://github.com/o/r/pull/7\",\"createdWithAgent\":\"claude\",\"baseBranch\":\"main\",\"linkedPR\":7}}" + }, + "be0ebed89b2b": { + "name": "navigation", + "value": "/h/host-1/session/wt-2?name=Recorded+pull+request&created=1&warning=shallow+clone" + }, + "c9cb32059b8d": { + "name": "creatingKey", + "value": { + "$rpc": "null" + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ecc2acd4d70d": { + "name": "worktree.resolvePrBase#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.resolvePrBase\",\"params\":{\"repo\":\"id:repo-1\",\"prNumber\":7}}" + }, + "ecefc28694cb": { + "name": "creatingKey", + "value": "github:7" + }, + "f9e183f427ee": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "prNumber": 7, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "baseBranch": "main" + } + } + } + } + }, + "recording": { + "scenario": "settings-task-workspace-create-pr-start-point", + "checkpoints": [ + { + "id": "settings-pending", + "observation": { + "sender": ["090c88478661"], + "payloads": ["7ddcb1852b39"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "9270aeb7d9c6" + }, + "state": "52051fd3214e", + "effects": ["ecefc28694cb", "82cd71d524c8"] + } + }, + { + "id": "pr-base-resolved", + "observation": { + "sender": ["2473f12c7cdd", "f9e183f427ee", "a49b109c46d4"], + "payloads": ["7ddcb1852b39", "ecc2acd4d70d", "b66f6d1958b9"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "9270aeb7d9c6" + }, + "state": "3dc266b1bda1", + "effects": ["ecefc28694cb", "82cd71d524c8", "067cef118d9f"] + } + }, + { + "id": "created-from-pr-base", + "observation": { + "sender": ["2473f12c7cdd", "f9e183f427ee", "9e9f36142bbd"], + "payloads": ["7ddcb1852b39", "ecc2acd4d70d", "b66f6d1958b9"], + "settlements": { + "mount": "eb79a9b3682a", + "submit": "eb79a9b3682a" + }, + "state": "33e3b949d4c5", + "effects": [ + "ecefc28694cb", + "82cd71d524c8", + "067cef118d9f", + "ac9996319e05", + "180125f5d1a6", + "6eb4e79ad99a", + "be0ebed89b2b", + "c9cb32059b8d" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json index cc7121f4087..b5f33c9b894 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "5c4c890e4c71e80fa8847a5e29700fc9df3ac3bd634bad6289db37522fadd621", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json index 809b521ec44..4801e2a0d73 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json @@ -3,9 +3,9 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a699a0a5b128fa422dab0c7557b5aa18599b2d23fa6685cdcc02e17edf328af1", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json index e21150ea4cd..98854fc1c5d 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json @@ -3,9 +3,9 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a5e812cd508826b3f01ec3798c621ab4303de6536a364113f01a4770dd197bb5", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-task-write.json b/mobile/rpc-foundation/goldens/settings-task-write.json index 000079c0ef8..db1240222c2 100644 --- a/mobile/rpc-foundation/goldens/settings-task-write.json +++ b/mobile/rpc-foundation/goldens/settings-task-write.json @@ -3,9 +3,9 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "bbcdefe16b07068a81f3c46ae60df01ccb0fbe5a7c1eade3f584f6f0130c23fe", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json index 8d7523fc0ec..dae8f376485 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "287f94e469548f28c9d5591ff6ffb916fa22caa18776b091542b75704c9e1fee", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json index 7636c928522..9999437344d 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json @@ -3,9 +3,9 @@ "family": "settings.workspace-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "76793a56e7b9e596d8c42e9a5a1c47337db32d7437bec2e41d6e7253943f3fd8", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json index 998f1287287..a8dc6c01abb 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json @@ -3,9 +3,9 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "ad19fc24973b49ee7d14bc31460a7e4af5d207db6a2375b52b5a0aa878e09205", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json index 5c97bea5c98..e8b23a18e38 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json @@ -3,9 +3,9 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "a2c80c9cdbb631f3a8fa648dfbb9e691418467d6ead8fe769d72e7e1d8b552b4", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json index ed74d84b1c7..6e0ec7222de 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json @@ -3,9 +3,9 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "b10ff86086c134284cb0446e8857cd4b55f5ff2bd0507388ec659a95f25e2a19", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json index 4b691635314..098936d586b 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json @@ -3,9 +3,9 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "373ea3743dac4e0845df01d5c8f75c909563b8c517f3858293a478234dc9ca5c", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json index a16a6b6b3c4..38b79386d3e 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json @@ -3,9 +3,9 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", - "recorderSha256": "58a461dbc99f5ce803aaec650e72eaa2934fccd405e56b77932286f05742ec6b", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", "scenarioSha256": "dfbacbd6392ae8e8199550952fe917e7c01182349df6c99a06eb0682cfd9175c", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json new file mode 100644 index 00000000000..8ee2df59655 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json @@ -0,0 +1,99 @@ +{ + "operation": "tasks.worktree-capabilities", + "family": "worktree.runtime-capabilities", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "9d79bcfd6957d11d5ce8c3296f1038a3cfab81eedad7f990b44071104dfd0f91", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "1e5b32902af7": { + "name": "status.get#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "5242fad3532f": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "capabilities": ["mobile.tasks.v1", "worktree.create-idempotency.v1"], + "platform": "linux", + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + } + } + } + } + }, + "62aaf19f0b16": { + "capabilities": { + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + } + } + }, + "b33d34bddc4e": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + } + } + } + }, + "recording": { + "scenario": "tw-capabilities-advertised", + "checkpoints": [ + { + "id": "probed", + "observation": { + "sender": ["5242fad3532f"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "b33d34bddc4e" + }, + "state": "62aaf19f0b16", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json new file mode 100644 index 00000000000..d5e2b80de00 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json @@ -0,0 +1,185 @@ +{ + "operation": "tasks.worktree-capabilities", + "family": "worktree.runtime-capabilities", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "fb9c0e4b7c34f9bd1bd355b606c6ba75a7583cff3788000b7ed013612f5574fe", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "1e5b32902af7": { + "name": "status.get#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "32354557bece": { + "capabilities": "unprobed" + }, + "4e6e53404f59": { + "capabilities": { + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": false + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "a8bcef1e95ed": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": true, + "worktreeCreateIdempotency": false + } + }, + "ae9ff6b74ec1": { + "name": "status.get#2", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "capabilities": ["mobile.tasks.v1"] + } + } + } + }, + "c0c86e67c300": { + "name": "status.get#2", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "c9c0513fdcb9": { + "name": "status.get#2", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "edf54746317d": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "LogicalClientCutoverError", + "message": "RPC interrupted by connection migration", + "isRpcDeliveryUnknown": true, + "cause": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + } + } + }, + "recording": { + "scenario": "tw-capabilities-cutover-retried", + "checkpoints": [ + { + "id": "reprobing-after-cutover", + "observation": { + "sender": ["edf54746317d", "c9c0513fdcb9"], + "payloads": ["1e5b32902af7", "c0c86e67c300"], + "settlements": { + "probe": "9270aeb7d9c6", + "migrate": "eb79a9b3682a" + }, + "state": "32354557bece", + "effects": [] + } + }, + { + "id": "probed-on-replacement", + "observation": { + "sender": ["edf54746317d", "ae9ff6b74ec1"], + "payloads": ["1e5b32902af7", "c0c86e67c300"], + "settlements": { + "probe": "a8bcef1e95ed", + "migrate": "eb79a9b3682a" + }, + "state": "4e6e53404f59", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json new file mode 100644 index 00000000000..a761daaf96d --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json @@ -0,0 +1,95 @@ +{ + "operation": "tasks.worktree-capabilities", + "family": "worktree.runtime-capabilities", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "d73fed49e0a7e3e054d5c2fa75780f98bf78b6fa7e02f1ccb2fdc49465cf2fe5", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "03f6a4ac937a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": false, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 60000 + } + } + }, + "1e5b32902af7": { + "name": "status.get#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}" + }, + "3b0c9705ec9a": { + "capabilities": { + "hostPlatform": { + "$rpc": "null" + }, + "tasksSupported": false, + "worktreeCreateIdempotency": { + "dedupeTtlMs": 60000 + } + } + }, + "488c988b5918": { + "name": "status.get#1", + "args": [ + { + "name": "method", + "value": "status.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "capabilities": ["worktree.create-idempotency.v1"] + } + } + } + } + }, + "recording": { + "scenario": "tw-capabilities-legacy-idempotency", + "checkpoints": [ + { + "id": "legacy-host-window", + "observation": { + "sender": ["488c988b5918"], + "payloads": ["1e5b32902af7"], + "settlements": { + "probe": "03f6a4ac937a" + }, + "state": "3b0c9705ec9a", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json new file mode 100644 index 00000000000..37bb5937cf6 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json @@ -0,0 +1,109 @@ +{ + "operation": "tasks.worktree-create-retry", + "family": "worktree.create-retry", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "a99bb80a5826af1df8d74114fcc5654aa42c9208747b512cea9bd5ca65b64ccb", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "3b42c7d5a39b": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection lost", + "isRpcDeliveryUnknown": true + } + } + }, + "3f946ad0279c": { + "outcome": "uncreated" + }, + "43a221c63628": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"kestrel\",\"clientMutationId\":\"mutation-1\"}}" + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "f0d75436c3f2": { + "status": "rejected", + "startedAt": 0, + "settledAt": 20000, + "error": { + "category": "Error", + "message": "Connection lost", + "isRpcDeliveryUnknown": true + } + } + }, + "recording": { + "scenario": "tw-create-retry-ambiguous-after-drop", + "checkpoints": [ + { + "id": "waiting-for-reconnect", + "observation": { + "sender": ["3b42c7d5a39b"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "9270aeb7d9c6", + "drop": "eb79a9b3682a" + }, + "state": "3f946ad0279c", + "effects": [] + } + }, + { + "id": "replay-window-abandoned", + "observation": { + "sender": ["3b42c7d5a39b"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "f0d75436c3f2", + "drop": "eb79a9b3682a" + }, + "state": "3f946ad0279c", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json new file mode 100644 index 00000000000..c684e951815 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json @@ -0,0 +1,83 @@ +{ + "operation": "tasks.worktree-create-retry", + "family": "worktree.create-retry", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "3cda09e4a4ad4092f5f9a48b7c9715a99a51eb3bc4bed00f7054537a9e21cea9", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "3f946ad0279c": { + "outcome": "uncreated" + }, + "43a221c63628": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"kestrel\",\"clientMutationId\":\"mutation-1\"}}" + }, + "4b9d2713abf2": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Request timed out", + "isRpcDeliveryUnknown": true + } + }, + "50eee544463d": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Request timed out", + "isRpcDeliveryUnknown": true + } + } + } + }, + "recording": { + "scenario": "tw-create-retry-ambiguous-while-connected", + "checkpoints": [ + { + "id": "unknown-not-failed", + "observation": { + "sender": ["50eee544463d"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "4b9d2713abf2" + }, + "state": "3f946ad0279c", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json new file mode 100644 index 00000000000..ab56121335a --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json @@ -0,0 +1,82 @@ +{ + "operation": "tasks.worktree-create-retry", + "family": "worktree.create-retry", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "61097c13b262a4454510936fa9c9554a07865b610f18407f1b67bdd74476df08", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "3f946ad0279c": { + "outcome": "uncreated" + }, + "6d0209806267": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection lost", + "isRpcDeliveryUnknown": true + } + }, + "99d539e63c12": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"kestrel\"}}" + }, + "a179866627c5": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection lost", + "isRpcDeliveryUnknown": true + } + } + } + }, + "recording": { + "scenario": "tw-create-retry-ambiguous-without-idempotency", + "checkpoints": [ + { + "id": "unstamped-create-is-not-replayed", + "observation": { + "sender": ["a179866627c5"], + "payloads": ["99d539e63c12"], + "settlements": { + "create": "6d0209806267" + }, + "state": "3f946ad0279c", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-created.json b/mobile/rpc-foundation/goldens/tw-create-retry-created.json new file mode 100644 index 00000000000..a217bd898bc --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-create-retry-created.json @@ -0,0 +1,90 @@ +{ + "operation": "tasks.worktree-create-retry", + "family": "worktree.create-retry", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "0278216fee698c00118bb0e73a7fe755dc59c3b8e2b0459153edae64f155774c", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "43a221c63628": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"kestrel\",\"clientMutationId\":\"mutation-1\"}}" + }, + "489c189aebca": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "worktree": { + "displayName": "kestrel", + "id": "repo-1::/w" + } + } + } + } + }, + "b32227fdb10b": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "name": "kestrel", + "worktreeId": "repo-1::/w" + } + }, + "df162b95f465": { + "outcome": { + "name": "kestrel", + "worktreeId": "repo-1::/w" + } + } + }, + "recording": { + "scenario": "tw-create-retry-created", + "checkpoints": [ + { + "id": "created", + "observation": { + "sender": ["489c189aebca"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "b32227fdb10b" + }, + "state": "df162b95f465", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json new file mode 100644 index 00000000000..89512856323 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json @@ -0,0 +1,176 @@ +{ + "operation": "tasks.worktree-create-retry", + "family": "worktree.create-retry", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "6cb658dadc9e146c4f36c6ce643451e72300b8cdda19cc684ffa9fb2b0822c0a", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "2b7c07c2d2af": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "conflict", + "message": "Branch \"kestrel\" already exists." + }, + "id": "frame-1", + "ok": false + } + } + }, + "3f946ad0279c": { + "outcome": "uncreated" + }, + "3fa75e508233": { + "name": "worktree.create#2", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"kestrel-2\",\"clientMutationId\":\"mutation-2\"}}" + }, + "43a221c63628": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"kestrel\",\"clientMutationId\":\"mutation-1\"}}" + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "96ffe866d064": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "name": "kestrel-2", + "worktreeId": "repo-1::/w2" + } + }, + "c277d86477c3": { + "name": "worktree.create#2", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-2", + "name": "kestrel-2", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "d0c9ecd48c97": { + "name": "worktree.create#2", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-2", + "name": "kestrel-2", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "worktree": { + "displayName": "kestrel-2", + "id": "repo-1::/w2" + } + } + } + } + }, + "db70004d62eb": { + "outcome": { + "name": "kestrel-2", + "worktreeId": "repo-1::/w2" + } + } + }, + "recording": { + "scenario": "tw-create-retry-name-collision", + "checkpoints": [ + { + "id": "retrying", + "observation": { + "sender": ["2b7c07c2d2af", "c277d86477c3"], + "payloads": ["43a221c63628", "3fa75e508233"], + "settlements": { + "create": "9270aeb7d9c6" + }, + "state": "3f946ad0279c", + "effects": [] + } + }, + { + "id": "created-suffixed", + "observation": { + "sender": ["2b7c07c2d2af", "d0c9ecd48c97"], + "payloads": ["43a221c63628", "3fa75e508233"], + "settlements": { + "create": "96ffe866d064" + }, + "state": "db70004d62eb", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json new file mode 100644 index 00000000000..ed6e4c5b69d --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json @@ -0,0 +1,86 @@ +{ + "operation": "tasks.worktree-create-retry", + "family": "worktree.create-retry", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "16abe9e1a8d4cff17b3ea29d40277ae30a3d555a4a9efa08b2745c3a85b02740", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "240b0b1c72b2": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": "" + } + }, + "43a221c63628": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"kestrel\",\"clientMutationId\":\"mutation-1\"}}" + }, + "43e8315bc2fe": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "internal", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "7d651cae8837": { + "outcome": { + "error": "" + } + } + }, + "recording": { + "scenario": "tw-create-retry-unretryable-refusal", + "checkpoints": [ + { + "id": "refused-empty-message", + "observation": { + "sender": ["43e8315bc2fe"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "240b0b1c72b2" + }, + "state": "7d651cae8837", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json new file mode 100644 index 00000000000..bccc6cbc9c0 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json @@ -0,0 +1,92 @@ +{ + "operation": "tasks.worktree-create-retry", + "family": "worktree.create-retry", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "f469b2b7d61e7fc500fa97b5548f5a3732b0dbcdb405412a514a609f786dfbb5", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "43a221c63628": { + "name": "worktree.create#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.create\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"kestrel\",\"clientMutationId\":\"mutation-1\"}}" + }, + "6ad759c47a41": { + "name": "worktree.create#1", + "args": [ + { + "name": "method", + "value": "worktree.create" + }, + { + "name": "params", + "value": { + "clientMutationId": "mutation-1", + "name": "kestrel", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 600000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "warning": " startup terminal failed ", + "worktree": { + "id": "repo-1::/w" + } + } + } + } + }, + "97555d579c32": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "name": "kestrel", + "warning": "startup terminal failed", + "worktreeId": "repo-1::/w" + } + }, + "f800fc04633e": { + "outcome": { + "name": "kestrel", + "warning": "startup terminal failed", + "worktreeId": "repo-1::/w" + } + } + }, + "recording": { + "scenario": "tw-create-retry-warning-kept", + "checkpoints": [ + { + "id": "created-with-warning", + "observation": { + "sender": ["6ad759c47a41"], + "payloads": ["43a221c63628"], + "settlements": { + "create": "97555d579c32" + }, + "state": "f800fc04633e", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json new file mode 100644 index 00000000000..dc96fe98d76 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json @@ -0,0 +1,158 @@ +{ + "operation": "tasks.composer-hosted-base", + "family": "worktree.hosted-base", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "b3734c24f8a083d3efcdd995ea6a57e608d3d3ae3bbbd514db19dcf69448fa48", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0e24d2a37a0d": { + "name": "worktree.resolvePrBase#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.resolvePrBase\",\"params\":{\"repo\":\"id:repo-1\",\"prNumber\":12,\"headRefName\":\"feature\"}}" + }, + "236529aa012d": { + "mrBase": "unresolved", + "prBase": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + }, + "3cca8119f144": { + "mrBase": { + "baseBranch": "develop" + }, + "prBase": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + }, + "4febe923ceea": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + } + } + }, + "5428de0f5130": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + }, + "69afcaf1cb72": { + "name": "worktree.resolveMrBase#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.resolveMrBase\",\"params\":{\"repo\":\"id:repo-1\",\"mrIid\":7,\"sourceBranch\":\"feature\"}}" + }, + "aad8e7ddeea2": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "baseBranch": "develop" + } + } + } + }, + "fd552ecb03da": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "baseBranch": "develop" + } + } + }, + "recording": { + "scenario": "tw-hosted-base-resolved", + "checkpoints": [ + { + "id": "pr-base-resolved", + "observation": { + "sender": ["4febe923ceea"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "5428de0f5130" + }, + "state": "236529aa012d", + "effects": [] + } + }, + { + "id": "mr-base-resolved", + "observation": { + "sender": ["4febe923ceea", "aad8e7ddeea2"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "5428de0f5130", + "mr": "fd552ecb03da" + }, + "state": "3cca8119f144", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json new file mode 100644 index 00000000000..4801966ea3f --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json @@ -0,0 +1,148 @@ +{ + "operation": "tasks.composer-hosted-base", + "family": "worktree.hosted-base", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "451e74a430d2549976fa360a0e43e76a8d855b1470b8e313797019770ceca4cb", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0e24d2a37a0d": { + "name": "worktree.resolvePrBase#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.resolvePrBase\",\"params\":{\"repo\":\"id:repo-1\",\"prNumber\":12,\"headRefName\":\"feature\"}}" + }, + "69afcaf1cb72": { + "name": "worktree.resolveMrBase#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.resolveMrBase\",\"params\":{\"repo\":\"id:repo-1\",\"mrIid\":7,\"sourceBranch\":\"feature\"}}" + }, + "723a115a3810": { + "name": "worktree.resolveMrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolveMrBase" + }, + { + "name": "params", + "value": { + "mrIid": 7, + "repo": "id:repo-1", + "sourceBranch": "feature" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "" + } + } + } + }, + "ae0c82b12f2a": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "pull request not found", + "isRpcDeliveryUnknown": false + } + }, + "c57e06c96492": { + "mrBase": "unresolved", + "prBase": "unresolved" + }, + "eb01c2306db5": { + "name": "worktree.resolvePrBase#1", + "args": [ + { + "name": "method", + "value": "worktree.resolvePrBase" + }, + { + "name": "params", + "value": { + "headRefName": "feature", + "prNumber": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "pull request not found" + } + } + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + } + }, + "recording": { + "scenario": "tw-hosted-base-soft-error", + "checkpoints": [ + { + "id": "in-band-error", + "observation": { + "sender": ["eb01c2306db5"], + "payloads": ["0e24d2a37a0d"], + "settlements": { + "pr": "ae0c82b12f2a" + }, + "state": "c57e06c96492", + "effects": [] + } + }, + { + "id": "in-band-empty-error", + "observation": { + "sender": ["eb01c2306db5", "723a115a3810"], + "payloads": ["0e24d2a37a0d", "69afcaf1cb72"], + "settlements": { + "pr": "ae0c82b12f2a", + "mr": "f3b516f62081" + }, + "state": "c57e06c96492", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json new file mode 100644 index 00000000000..4f3ca9bd3e4 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json @@ -0,0 +1,340 @@ +{ + "operation": "tasks.paste-lookup", + "family": "tasks.paste-lookup", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "1fb1cdc8a2544e25547175760143a61355900a3ed4b87e08a1fa0dd2409e317d", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "09b235c17bb0": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "0e9d6525a582": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "11ab96fde6c9": { + "name": "gitlab.workItemByPath#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.workItemByPath\",\"params\":{\"repo\":\"id:repo-1\",\"host\":\"gitlab.com\",\"path\":\"group/project\",\"iid\":7,\"type\":\"issue\"}}" + }, + "2113a0cc7708": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [ + [ + "repo-1", + { + "owner": "owner", + "repo": "repo" + } + ] + ], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + }, + "repo-slug": { + "displayName": "Repo", + "id": "repo-1", + "slug": { + "$rpc": "null" + } + } + }, + "4a3429622287": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [], + "gitlab-path": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "65342779da15": { + "name": "github.workItem#1", + "args": [ + { + "name": "method", + "value": "github.workItem" + }, + { + "name": "params", + "value": { + "number": 12, + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + } + }, + "731507dd2e23": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + } + }, + "7445a582a9c8": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "owner": "owner", + "repo": "repo" + } + } + } + }, + "a45a7dd68af6": { + "name": "github.repoSlug#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"github.repoSlug\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "aaf80675fc49": { + "name": "github.workItem#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.workItem\",\"params\":{\"repo\":\"id:repo-1\",\"number\":12}}" + }, + "bd533f6b0b40": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "iid": 7, + "repoId": "repo-1", + "title": "seven" + } + }, + "e1f537905a65": { + "name": "github.workItemByOwnerRepo#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.workItemByOwnerRepo\",\"params\":{\"repo\":\"id:repo-1\",\"owner\":\"owner\",\"ownerRepo\":\"repo\",\"number\":12,\"type\":\"issue\"}}" + }, + "e29333b1693f": { + "name": "gitlab.workItemByPath#1", + "args": [ + { + "name": "method", + "value": "gitlab.workItemByPath" + }, + { + "name": "params", + "value": { + "host": "gitlab.com", + "iid": 7, + "path": "group/project", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "iid": 7, + "title": "seven" + } + } + } + }, + "e970eb27f5ca": { + "by-number": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "by-slug": { + "number": 12, + "repoId": "repo-1", + "title": "twelve" + }, + "cache": [] + }, + "f9ea1f747023": { + "name": "github.workItemByOwnerRepo#1", + "args": [ + { + "name": "method", + "value": "github.workItemByOwnerRepo" + }, + { + "name": "params", + "value": { + "number": 12, + "owner": "owner", + "ownerRepo": "repo", + "repo": "id:repo-1", + "type": "issue" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + } + } + }, + "recording": { + "scenario": "tw-paste-lookup-resolved", + "checkpoints": [ + { + "id": "by-number", + "observation": { + "sender": ["65342779da15"], + "payloads": ["aaf80675fc49"], + "settlements": { + "by-number": "731507dd2e23" + }, + "state": "09b235c17bb0", + "effects": [] + } + }, + { + "id": "by-slug", + "observation": { + "sender": ["65342779da15", "f9ea1f747023"], + "payloads": ["aaf80675fc49", "e1f537905a65"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23" + }, + "state": "e970eb27f5ca", + "effects": [] + } + }, + { + "id": "gitlab-path", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40" + }, + "state": "4a3429622287", + "effects": [] + } + }, + { + "id": "repo-slug-matched", + "observation": { + "sender": ["65342779da15", "f9ea1f747023", "e29333b1693f", "7445a582a9c8"], + "payloads": ["aaf80675fc49", "e1f537905a65", "11ab96fde6c9", "a45a7dd68af6"], + "settlements": { + "by-number": "731507dd2e23", + "by-slug": "731507dd2e23", + "gitlab-path": "bd533f6b0b40", + "repo-slug": "0e9d6525a582" + }, + "state": "2113a0cc7708", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json new file mode 100644 index 00000000000..d4518e0002d --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json @@ -0,0 +1,135 @@ +{ + "operation": "tasks.paste-lookup", + "family": "tasks.paste-lookup", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "e9d85c576adf93063a8f56d49d28869c402cad38122732b46b8ec021d25db5e3", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0b81b65669e6": { + "name": "github.repoSlug#2", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-2" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "2d9e475c68c7": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "forbidden", + "message": "no access" + }, + "id": "frame-1", + "ok": false + } + } + }, + "35f3e39a1c50": { + "cache": [ + [ + "repo-1", + { + "$rpc": "null" + } + ], + [ + "repo-2", + { + "$rpc": "null" + } + ] + ], + "repo-slug": { + "$rpc": "null" + } + }, + "5f7cab1e0f03": { + "name": "github.repoSlug#2", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"github.repoSlug\",\"params\":{\"repo\":\"id:repo-2\"}}" + }, + "6530ef4dbd15": { + "name": "github.repoSlug#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.repoSlug\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "ee20a1dc39e7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "null" + } + } + }, + "recording": { + "scenario": "tw-paste-lookup-slug-refused", + "checkpoints": [ + { + "id": "refusal-is-per-repo", + "observation": { + "sender": ["2d9e475c68c7", "0b81b65669e6"], + "payloads": ["6530ef4dbd15", "5f7cab1e0f03"], + "settlements": { + "repo-slug": "ee20a1dc39e7" + }, + "state": "35f3e39a1c50", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json new file mode 100644 index 00000000000..63595fb6223 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json @@ -0,0 +1,133 @@ +{ + "operation": "tasks.paste-lookup", + "family": "tasks.paste-lookup", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "14509b4c1cc3beb00cc329b6bae46913f59b3938f76c0bd3bf3e374f34fb680d", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0f35c09b3d1e": { + "name": "github.repoSlug#1", + "args": [ + { + "name": "method", + "value": "github.repoSlug" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-1", + "ok": false + } + } + }, + "176039835400": { + "cache": [ + [ + "repo-1", + { + "$rpc": "null" + } + ], + [ + "repo-2", + { + "$rpc": "null" + } + ] + ], + "repo-slug": { + "$rpc": "null" + }, + "repo-slug-again": { + "$rpc": "null" + } + }, + "35f3e39a1c50": { + "cache": [ + [ + "repo-1", + { + "$rpc": "null" + } + ], + [ + "repo-2", + { + "$rpc": "null" + } + ] + ], + "repo-slug": { + "$rpc": "null" + } + }, + "6530ef4dbd15": { + "name": "github.repoSlug#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.repoSlug\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "ee20a1dc39e7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "null" + } + } + }, + "recording": { + "scenario": "tw-paste-lookup-slug-unsupported", + "checkpoints": [ + { + "id": "host-wide-probe-cached", + "observation": { + "sender": ["0f35c09b3d1e"], + "payloads": ["6530ef4dbd15"], + "settlements": { + "repo-slug": "ee20a1dc39e7" + }, + "state": "35f3e39a1c50", + "effects": [] + } + }, + { + "id": "no-second-probe", + "observation": { + "sender": ["0f35c09b3d1e"], + "payloads": ["6530ef4dbd15"], + "settlements": { + "repo-slug": "ee20a1dc39e7", + "repo-slug-again": "ee20a1dc39e7" + }, + "state": "176039835400", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json new file mode 100644 index 00000000000..6197696ed6d --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json @@ -0,0 +1,90 @@ +{ + "operation": "tasks.setup-hook-trust", + "family": "worktree.setup-hook-trust", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "0d2e3f48aadf45abbf6927b72ed5fef4caa8e3eb2efd1046339a3fbfab6f9f18", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "229c35d1a4ba": { + "trust": "unapproved" + }, + "2fde86b1acca": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "all": { + "approvedAt": 1767225600000 + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "forbidden", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "341f646a48a2": { + "name": "ui.set#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"trustedOrcaHooks\":{\"repo-1\":{\"all\":{\"approvedAt\":1767225600000}}}}}" + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + } + }, + "recording": { + "scenario": "tw-setup-hook-trust-always", + "checkpoints": [ + { + "id": "refused-empty-message", + "observation": { + "sender": ["2fde86b1acca"], + "payloads": ["341f646a48a2"], + "settlements": { + "approve": "f3b516f62081" + }, + "state": "229c35d1a4ba", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json new file mode 100644 index 00000000000..6c27989310b --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json @@ -0,0 +1,100 @@ +{ + "operation": "tasks.setup-hook-trust", + "family": "worktree.setup-hook-trust", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "f54f5c6dbbe7dbcf8e85e9bd36b27ca9bea7de65d5e35dabace49b3fc766a403", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0f68ccbfb8e9": { + "name": "ui.set#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"trustedOrcaHooks\":{\"repo-1\":{\"setup\":{\"contentHash\":\"hash-1\",\"approvedAt\":1767225600000}}}}}" + }, + "6f009f61d89f": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "ok": true + } + } + } + }, + "a406b068aeca": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + }, + "b2aa9ff12623": { + "trust": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + "recording": { + "scenario": "tw-setup-hook-trust-approved", + "checkpoints": [ + { + "id": "approved", + "observation": { + "sender": ["6f009f61d89f"], + "payloads": ["0f68ccbfb8e9"], + "settlements": { + "approve": "a406b068aeca" + }, + "state": "b2aa9ff12623", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json new file mode 100644 index 00000000000..c8b2709e36e --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json @@ -0,0 +1,489 @@ +{ + "operation": "tasks.smart-source-search", + "family": "tasks.smart-source-search", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "283849e17fb47ad5f9c128cef37a18e869a132357b332b40bec955292db2af3f", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "253629bd0d20": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "25f88995b39a": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "refs": ["main", "release"] + } + } + } + }, + "26dc3b7c8299": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-3" + } + ] + }, + "2cfd107b9660": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "36290ab254a4": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ] + }, + "3828d5880c35": { + "name": "gitlab.listWorkItems#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"state\":\"opened\",\"page\":1,\"perPage\":50,\"query\":\"bug\"}}" + }, + "41d2452d4ebe": { + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "5bce68072dc3": { + "name": "github.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "github.listWorkItems" + }, + { + "name": "params", + "value": { + "limit": 36, + "query": "bug", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "items": [ + { + "number": 1, + "title": "one" + } + ] + } + } + } + }, + "6107c951646f": { + "name": "linear.searchIssues#1", + "args": [ + { + "name": "method", + "value": "linear.searchIssues" + }, + { + "name": "params", + "value": { + "limit": 50, + "query": "bug", + "workspaceId": "linear-workspace" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "items": [ + { + "id": "issue-1" + } + ] + } + } + } + }, + "6e2d75e3bbd7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ] + }, + "a4ee5d16b4f6": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-1" + } + ] + }, + "a92cd1dd05af": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-3" + } + ] + }, + "b015aaf3a53a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ] + }, + "b8b02a30b6b8": { + "name": "repo.searchRefs#1", + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"repo.searchRefs\",\"params\":{\"repo\":\"id:repo-1\",\"query\":\"bug\",\"limit\":20}}" + }, + "c43e80126d82": { + "branches": [ + { + "localBranchName": "main", + "refName": "main" + }, + { + "localBranchName": "release", + "refName": "release" + } + ], + "github": [ + { + "number": 1, + "repoId": "repo-1", + "title": "one" + } + ], + "gitlab": [ + { + "iid": 2, + "repoId": "repo-1", + "title": "two" + } + ], + "linear": [ + { + "id": "issue-1" + } + ] + }, + "e97e5a589476": { + "name": "linear.listIssues#1", + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"linear.listIssues\",\"params\":{\"filter\":\"assigned\",\"limit\":50}}" + }, + "ead829dd6d03": { + "name": "linear.searchIssues#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"linear.searchIssues\",\"params\":{\"query\":\"bug\",\"limit\":50,\"workspaceId\":\"linear-workspace\"}}" + }, + "ee6fe4f97b01": { + "name": "github.listWorkItems#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"limit\":36,\"query\":\"bug\"}}" + }, + "f32ad26605d0": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": { + "message": "missing", + "type": "not_found" + }, + "items": [ + { + "iid": 2, + "title": "two" + } + ] + } + } + } + }, + "fe7f60b5d785": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-5", + "ok": true, + "result": [ + { + "id": "issue-3" + } + ] + } + } + } + }, + "recording": { + "scenario": "tw-smart-search-all-providers", + "checkpoints": [ + { + "id": "github-items", + "observation": { + "sender": ["5bce68072dc3"], + "payloads": ["ee6fe4f97b01"], + "settlements": { + "github": "36290ab254a4" + }, + "state": "253629bd0d20", + "effects": [] + } + }, + { + "id": "gitlab-items", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0"], + "payloads": ["ee6fe4f97b01", "3828d5880c35"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7" + }, + "state": "2cfd107b9660", + "effects": [] + } + }, + { + "id": "linear-search", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6" + }, + "state": "41d2452d4ebe", + "effects": [] + } + }, + { + "id": "branch-refs", + "observation": { + "sender": ["5bce68072dc3", "f32ad26605d0", "6107c951646f", "25f88995b39a"], + "payloads": ["ee6fe4f97b01", "3828d5880c35", "ead829dd6d03", "b8b02a30b6b8"], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a" + }, + "state": "c43e80126d82", + "effects": [] + } + }, + { + "id": "linear-assigned-listed", + "observation": { + "sender": [ + "5bce68072dc3", + "f32ad26605d0", + "6107c951646f", + "25f88995b39a", + "fe7f60b5d785" + ], + "payloads": [ + "ee6fe4f97b01", + "3828d5880c35", + "ead829dd6d03", + "b8b02a30b6b8", + "e97e5a589476" + ], + "settlements": { + "github": "36290ab254a4", + "gitlab": "6e2d75e3bbd7", + "linear": "a4ee5d16b4f6", + "branches": "b015aaf3a53a", + "linear-assigned": "26dc3b7c8299" + }, + "state": "a92cd1dd05af", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json new file mode 100644 index 00000000000..a7112240524 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json @@ -0,0 +1,165 @@ +{ + "operation": "tasks.smart-source-search", + "family": "tasks.smart-source-search", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "4537bf83f7a030521eec549adf4490da5be183471d5a9f9e58b71815b29481ff", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "44136fa355b3": {}, + "46027e62015d": { + "name": "repo.searchRefs#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"repo.searchRefs\",\"params\":{\"repo\":\"id:repo-1\",\"query\":\"main\",\"limit\":20}}" + }, + "51e012c25ebf": { + "branches": [ + { + "localBranchName": "main", + "refName": "origin/main" + } + ] + }, + "522d9e5c292e": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "refDetails": [ + { + "localBranchName": "main", + "refName": "origin/main" + } + ] + } + } + } + }, + "791f6fc629fc": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "localBranchName": "main", + "refName": "origin/main" + } + ] + }, + "86057be07bd0": { + "name": "gitlab.listWorkItems#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"gitlab.listWorkItems\",\"params\":{\"repo\":\"id:repo-1\",\"state\":\"opened\",\"page\":1,\"perPage\":50,\"query\":\"bug\"}}" + }, + "97c2301d5d8c": { + "name": "gitlab.listWorkItems#1", + "args": [ + { + "name": "method", + "value": "gitlab.listWorkItems" + }, + { + "name": "params", + "value": { + "page": 1, + "perPage": 50, + "query": "bug", + "repo": "id:repo-1", + "state": "opened" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": { + "message": "rate limited", + "type": "quota" + }, + "items": [] + } + } + } + }, + "bf7ab976b200": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "rate limited", + "isRpcDeliveryUnknown": false + } + } + }, + "recording": { + "scenario": "tw-smart-search-gitlab-provider-error", + "checkpoints": [ + { + "id": "in-band-provider-error", + "observation": { + "sender": ["97c2301d5d8c"], + "payloads": ["86057be07bd0"], + "settlements": { + "gitlab": "bf7ab976b200" + }, + "state": "44136fa355b3", + "effects": [] + } + }, + { + "id": "branch-ref-details", + "observation": { + "sender": ["97c2301d5d8c", "522d9e5c292e"], + "payloads": ["86057be07bd0", "46027e62015d"], + "settlements": { + "gitlab": "bf7ab976b200", + "branches": "791f6fc629fc" + }, + "state": "51e012c25ebf", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json new file mode 100644 index 00000000000..00a74986bbe --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json @@ -0,0 +1,93 @@ +{ + "operation": "tasks.smart-source-search", + "family": "tasks.smart-source-search", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "34ab4edb621856980c8678629bd809c100d27dcd0747db5abbf4508c7231b7e5", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "4e3aac46030e": { + "name": "linear.listIssues#1", + "args": [ + { + "name": "method", + "value": "linear.listIssues" + }, + { + "name": "params", + "value": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$rpc": "undefined" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": [ + { + "id": "issue-2" + } + ] + } + } + }, + "a95bdb94e589": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": [ + { + "id": "issue-2" + } + ] + }, + "b107467b4d7c": { + "name": "linear.listIssues#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"linear.listIssues\",\"params\":{\"filter\":\"assigned\",\"limit\":50}}" + }, + "f2b981b0b281": { + "linear": [ + { + "id": "issue-2" + } + ] + } + }, + "recording": { + "scenario": "tw-smart-search-linear-listed", + "checkpoints": [ + { + "id": "linear-assigned", + "observation": { + "sender": ["4e3aac46030e"], + "payloads": ["b107467b4d7c"], + "settlements": { + "linear": "a95bdb94e589" + }, + "state": "f2b981b0b281", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json new file mode 100644 index 00000000000..131cccfa495 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json @@ -0,0 +1,154 @@ +{ + "operation": "settings.task-preferences", + "family": "settings-best-effort", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "31bfa49f888b0eb3f72873bf4a3af26129e78c23126e8fc8fe45b952caa60904", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "43a97b36b849": { + "name": "ui.set#2", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"trustedOrcaHooks\":{\"repo-1\":{\"setup\":{\"contentHash\":\"hash-1\",\"approvedAt\":1767225600000}}}}}" + }, + "8214f29cee6d": { + "name": "ui.set#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ui.set\",\"params\":{\"taskResumeState\":{\"githubItemsPreset\":\"issues\"}}}" + }, + "a569eb8ebbdd": { + "name": "ui.set#1", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "taskResumeState": { + "githubItemsPreset": "issues" + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "forbidden", + "message": "no access" + }, + "id": "frame-1", + "ok": false + } + } + }, + "ae0c8e22f430": { + "preset": "all" + }, + "bea815de84ac": { + "name": "ui.set#2", + "args": [ + { + "name": "method", + "value": "ui.set" + }, + { + "name": "params", + "value": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "approvedAt": 1767225600000, + "contentHash": "hash-1" + } + } + } + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "forbidden", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "f3b516f62081": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": false + } + } + }, + "recording": { + "scenario": "tw-task-preferences-resume-write", + "checkpoints": [ + { + "id": "best-effort-resume-write", + "observation": { + "sender": ["a569eb8ebbdd"], + "payloads": ["8214f29cee6d"], + "settlements": { + "mount": "eb79a9b3682a", + "resume": "eb79a9b3682a" + }, + "state": "ae0c8e22f430", + "effects": [] + } + }, + { + "id": "awaited-trust-write-refused", + "observation": { + "sender": ["a569eb8ebbdd", "bea815de84ac"], + "payloads": ["8214f29cee6d", "43a97b36b849"], + "settlements": { + "mount": "eb79a9b3682a", + "resume": "eb79a9b3682a", + "trust": "f3b516f62081" + }, + "state": "ae0c8e22f430", + "effects": [] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json new file mode 100644 index 00000000000..be5d800dfa5 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json @@ -0,0 +1,136 @@ +{ + "operation": "tasks.workspace-source", + "family": "tasks.workspace-source", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "2cd1e8972f226572744dad7da82afffbdf0452a121c1cd8c3334d5c3fde5d57c", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "2399e995a370": { + "name": "workspaceSparsePresets", + "value": [] + }, + "273f4074a9b5": { + "name": "workspaceSparsePresetsLoaded", + "value": false + }, + "35afa5cb107f": { + "name": "workspaceBaseBranchLoading", + "value": false + }, + "4cedb91a2f7a": { + "name": "repo.sparsePresets#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.sparsePresets\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "5bad21b1e042": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "forbidden", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "5f1c84e00d4f": { + "name": "workspaceBaseBranchResults", + "value": [] + }, + "62bc28c39ffc": { + "branchError": "", + "branches": [], + "presets": [], + "presetsError": "", + "presetsLoaded": false + }, + "6c344c5f4ac0": { + "name": "workspaceBaseBranchError", + "value": "" + }, + "8353b8e1a426": { + "name": "workspaceSparsePresetsLoading", + "value": true + }, + "9357f7ea8445": { + "name": "workspaceSparsePresetId", + "value": { + "$rpc": "null" + } + }, + "cfc8af2a7169": { + "name": "workspaceSparsePresetsLoading", + "value": false + }, + "dba381378b08": { + "name": "workspaceSparsePresetsError", + "value": "" + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + } + }, + "recording": { + "scenario": "tw-workspace-source-presets-refused", + "checkpoints": [ + { + "id": "presets-refused-empty-message", + "observation": { + "sender": ["5bad21b1e042"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "62bc28c39ffc", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "2399e995a370", + "273f4074a9b5", + "9357f7ea8445", + "dba381378b08", + "cfc8af2a7169" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json new file mode 100644 index 00000000000..c740ccb382e --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json @@ -0,0 +1,254 @@ +{ + "operation": "tasks.workspace-source", + "family": "tasks.workspace-source", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "046dd3a125a3c9abcf5a0dd122818939b516adb91cbda2554b3409d4bb3a7980", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "1d9a7d969446": { + "name": "workspaceSparsePresetsLoaded", + "value": true + }, + "273f4074a9b5": { + "name": "workspaceSparsePresetsLoaded", + "value": false + }, + "35afa5cb107f": { + "name": "workspaceBaseBranchLoading", + "value": false + }, + "395368dea8ff": { + "name": "repo.searchRefs#1", + "args": [ + { + "name": "method", + "value": "repo.searchRefs" + }, + { + "name": "params", + "value": { + "limit": 20, + "query": "main", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 30000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "refs": ["main"] + } + } + } + }, + "46027e62015d": { + "name": "repo.searchRefs#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"repo.searchRefs\",\"params\":{\"repo\":\"id:repo-1\",\"query\":\"main\",\"limit\":20}}" + }, + "4856f62b3650": { + "name": "workspaceSparsePresets", + "value": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ] + }, + "4cedb91a2f7a": { + "name": "repo.sparsePresets#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.sparsePresets\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "57f06e6e349e": { + "branchError": "", + "branches": [], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "58cb95babab2": { + "name": "workspaceBaseBranchLoading", + "value": true + }, + "5f1c84e00d4f": { + "name": "workspaceBaseBranchResults", + "value": [] + }, + "6c344c5f4ac0": { + "name": "workspaceBaseBranchError", + "value": "" + }, + "8353b8e1a426": { + "name": "workspaceSparsePresetsLoading", + "value": true + }, + "8dbe7ea87a41": { + "name": "workspaceBaseBranchResults", + "value": [ + { + "localBranchName": "main", + "refName": "main" + } + ] + }, + "9357f7ea8445": { + "name": "workspaceSparsePresetId", + "value": { + "$rpc": "null" + } + }, + "b78bcf7ca596": { + "branchError": "", + "branches": [ + { + "localBranchName": "main", + "refName": "main" + } + ], + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "presetsLoaded": true + }, + "c8d4d05367d6": { + "name": "repo.sparsePresets#1", + "args": [ + { + "name": "method", + "value": "repo.sparsePresets" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ] + } + } + } + }, + "cfc8af2a7169": { + "name": "workspaceSparsePresetsLoading", + "value": false + }, + "dba381378b08": { + "name": "workspaceSparsePresetsError", + "value": "" + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + } + }, + "recording": { + "scenario": "tw-workspace-source-presets", + "checkpoints": [ + { + "id": "presets-loaded", + "observation": { + "sender": ["c8d4d05367d6"], + "payloads": ["4cedb91a2f7a"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "57f06e6e349e", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169" + ] + } + }, + { + "id": "branches-loaded", + "observation": { + "sender": ["c8d4d05367d6", "395368dea8ff"], + "payloads": ["4cedb91a2f7a", "46027e62015d"], + "settlements": { + "mount": "eb79a9b3682a", + "branch-query": "eb79a9b3682a" + }, + "state": "b78bcf7ca596", + "effects": [ + "8353b8e1a426", + "273f4074a9b5", + "dba381378b08", + "5f1c84e00d4f", + "35afa5cb107f", + "6c344c5f4ac0", + "4856f62b3650", + "1d9a7d969446", + "9357f7ea8445", + "cfc8af2a7169", + "58cb95babab2", + "6c344c5f4ac0", + "8dbe7ea87a41", + "35afa5cb107f" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json new file mode 100644 index 00000000000..f6f7cb190d4 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json @@ -0,0 +1,159 @@ +{ + "operation": "tasks.workspace-sparse", + "family": "tasks.workspace-sparse", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "865a659012dd882fd6073813585e2911a1d6252404fbf5a5e273f062b89fc91d", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "193c0bc3cf2a": { + "presets": [], + "presetsError": "Failed to save sparse preset.", + "saving": false, + "ssh": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "7fd0cde62993": { + "name": "workspaceSparseSaving", + "value": false + }, + "86cc01b1e541": { + "name": "workspaceSshState", + "value": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "a6bf06ff84e0": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": {} + } + } + }, + "cea9d7e8986e": { + "name": "workspaceSparseSaving", + "value": true + }, + "da3a01640280": { + "name": "workspaceSparsePresetsError", + "value": "Failed to save sparse preset." + }, + "dba381378b08": { + "name": "workspaceSparsePresetsError", + "value": "" + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "f22d3216eb8d": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "forbidden", + "message": "" + }, + "id": "frame-1", + "ok": false + } + } + }, + "f9dfbe0c0ea7": { + "name": "ssh.getState#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.getState\",\"params\":{\"targetId\":\"ssh-1\"}}" + }, + "fd758406cc2c": { + "name": "repo.saveSparsePreset#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"repo.saveSparsePreset\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"docs\",\"directories\":[\"docs\"]}}" + } + }, + "recording": { + "scenario": "tw-workspace-sparse-missing-preset", + "checkpoints": [ + { + "id": "saved-without-preset", + "observation": { + "sender": ["f22d3216eb8d", "a6bf06ff84e0"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "193c0bc3cf2a", + "effects": [ + "86cc01b1e541", + "cea9d7e8986e", + "dba381378b08", + "da3a01640280", + "7fd0cde62993" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json new file mode 100644 index 00000000000..a2d609dc7b5 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json @@ -0,0 +1,229 @@ +{ + "operation": "tasks.workspace-sparse", + "family": "tasks.workspace-sparse", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "124f664e339bfd83a1d892e1cd953a78fdf0dc4b20c4272356d24079c72a3e04", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "1d9a7d969446": { + "name": "workspaceSparsePresetsLoaded", + "value": true + }, + "312ed3cbf468": { + "name": "workspaceSparsePresetId", + "value": "p1" + }, + "404305aa2e3a": { + "presets": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ], + "presetsError": "", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "42bbd034563e": { + "name": "workspaceSparseDraft", + "value": { + "$rpc": "null" + } + }, + "4856f62b3650": { + "name": "workspaceSparsePresets", + "value": [ + { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + ] + }, + "5c44ff5f6877": { + "name": "repo.saveSparsePreset#1", + "args": [ + { + "name": "method", + "value": "repo.saveSparsePreset" + }, + { + "name": "params", + "value": { + "directories": ["docs"], + "name": "docs", + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "preset": { + "directories": ["docs"], + "id": "p1", + "name": "docs" + } + } + } + } + }, + "7fd0cde62993": { + "name": "workspaceSparseSaving", + "value": false + }, + "89aa7a3bd619": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "state": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + } + } + } + }, + "921f72d7827e": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "cea9d7e8986e": { + "name": "workspaceSparseSaving", + "value": true + }, + "dba381378b08": { + "name": "workspaceSparsePresetsError", + "value": "" + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ee3a941d5e9c": { + "presets": [], + "presetsError": "", + "saving": false, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "f9dfbe0c0ea7": { + "name": "ssh.getState#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.getState\",\"params\":{\"targetId\":\"ssh-1\"}}" + }, + "fd758406cc2c": { + "name": "repo.saveSparsePreset#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"repo.saveSparsePreset\",\"params\":{\"repo\":\"id:repo-1\",\"name\":\"docs\",\"directories\":[\"docs\"]}}" + } + }, + "recording": { + "scenario": "tw-workspace-sparse-saved", + "checkpoints": [ + { + "id": "ssh-state-read", + "observation": { + "sender": ["89aa7a3bd619"], + "payloads": ["f9dfbe0c0ea7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "ee3a941d5e9c", + "effects": ["921f72d7827e"] + } + }, + { + "id": "preset-saved", + "observation": { + "sender": ["89aa7a3bd619", "5c44ff5f6877"], + "payloads": ["f9dfbe0c0ea7", "fd758406cc2c"], + "settlements": { + "mount": "eb79a9b3682a", + "save": "eb79a9b3682a" + }, + "state": "404305aa2e3a", + "effects": [ + "921f72d7827e", + "cea9d7e8986e", + "dba381378b08", + "4856f62b3650", + "1d9a7d969446", + "312ed3cbf468", + "42bbd034563e", + "7fd0cde62993" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json new file mode 100644 index 00000000000..0ae438cc3a3 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json @@ -0,0 +1,277 @@ +{ + "operation": "tasks.workspace-ssh", + "family": "tasks.workspace-ssh", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "dd25391fdd3dc864ae493f72d013e789884a21e9c71522edc79323bc2b6c7f76", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "12826f529c2a": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "ssh_failed", + "message": "" + }, + "id": "frame-2", + "ok": false + } + } + }, + "1739575ac53e": { + "name": "workspaceSshConnecting", + "value": true + }, + "27b09a2898b9": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection lost", + "isRpcDeliveryUnknown": true + } + } + }, + "2d313c57ddf7": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "decision": "skip", + "kind": "decision", + "setupTrust": { + "$rpc": "undefined" + } + } + }, + "37921d9fdeb7": { + "name": "preflight.detectRemoteAgents#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" + }, + "41b0d115f434": { + "name": "workspaceDetectedAgentIds", + "value": { + "$rpc": "null" + } + }, + "43fd3e2f4b53": { + "name": "workspaceSshConnecting", + "value": false + }, + "55904d40a00f": { + "agent": "claude", + "connecting": false, + "detected": [], + "setup": { + "decision": "skip", + "kind": "decision", + "setupTrust": { + "$rpc": "undefined" + } + }, + "ssh": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "7c9498659f58": { + "name": "ssh.connect#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}" + }, + "8509334ad6ae": { + "agent": "claude", + "connecting": false, + "detected": [], + "setup": "unresolved", + "ssh": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "86cc01b1e541": { + "name": "workspaceSshState", + "value": { + "error": "", + "reconnectAttempt": 0, + "status": "error", + "targetId": "ssh-1" + } + }, + "9f152ed6e897": { + "name": "workspaceDetectedAgentIds", + "value": [] + }, + "b302d21e1567": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "hooks": { + "scripts": { + "setup": "pnpm i" + } + }, + "setupRunPolicy": "never" + } + } + } + }, + "ea709e13f0f0": { + "name": "workspaceAgentOverridden", + "value": false + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ed6189938d78": { + "name": "workspaceAgent", + "value": "claude" + }, + "f0a9f62da106": { + "name": "repo.hooks#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"repo.hooks\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "fbfdbb919268": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connecting", + "targetId": "ssh-1" + } + } + }, + "recording": { + "scenario": "tw-workspace-ssh-connect-refused", + "checkpoints": [ + { + "id": "connect-refused-empty-message", + "observation": { + "sender": ["27b09a2898b9", "12826f529c2a"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "8509334ad6ae", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "86cc01b1e541", + "43fd3e2f4b53" + ] + } + }, + { + "id": "setup-skipped", + "observation": { + "sender": ["27b09a2898b9", "12826f529c2a", "b302d21e1567"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "2d313c57ddf7" + }, + "state": "55904d40a00f", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "1739575ac53e", + "fbfdbb919268", + "86cc01b1e541", + "43fd3e2f4b53" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json new file mode 100644 index 00000000000..6f042aac899 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json @@ -0,0 +1,319 @@ +{ + "operation": "tasks.workspace-ssh", + "family": "tasks.workspace-ssh", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "495f51d9c2f7f3d71f53a53e88786b8d1f767a5bf66b8655c28222d2909a964c", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0ba2cee4b538": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + } + }, + "1739575ac53e": { + "name": "workspaceSshConnecting", + "value": true + }, + "17e35b25d15d": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": ["codex"] + } + } + }, + "18e6a3ac6471": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "$rpc": "null" + } + }, + "3571f351281f": { + "name": "workspaceDetectedAgentIds", + "value": ["codex"] + }, + "37921d9fdeb7": { + "name": "preflight.detectRemoteAgents#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" + }, + "41b0d115f434": { + "name": "workspaceDetectedAgentIds", + "value": { + "$rpc": "null" + } + }, + "43ead075ce12": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": { + "command": "pnpm install", + "kind": "prompt", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + }, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "43fd3e2f4b53": { + "name": "workspaceSshConnecting", + "value": false + }, + "71d817ffdd81": { + "name": "ssh.connect#1", + "args": [ + { + "name": "method", + "value": "ssh.connect" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "timeoutMs": 120000 + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "state": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + } + } + } + }, + "7c9498659f58": { + "name": "ssh.connect#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.connect\",\"params\":{\"targetId\":\"ssh-1\"}}" + }, + "80a4af19f556": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "hooks": { + "scripts": { + "setup": " pnpm install " + } + }, + "setupRunPolicy": "ask", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + }, + "source": "repo" + } + } + } + }, + "921f72d7827e": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "a1f755a38636": { + "agent": "claude", + "connecting": false, + "detected": ["codex"], + "setup": "unresolved", + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connected", + "targetId": "ssh-1" + } + }, + "ea709e13f0f0": { + "name": "workspaceAgentOverridden", + "value": false + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ed6189938d78": { + "name": "workspaceAgent", + "value": "claude" + }, + "f0a9f62da106": { + "name": "repo.hooks#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"repo.hooks\",\"params\":{\"repo\":\"id:repo-1\"}}" + }, + "fbfdbb919268": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "connecting", + "targetId": "ssh-1" + } + } + }, + "recording": { + "scenario": "tw-workspace-ssh-connected", + "checkpoints": [ + { + "id": "agents-detected", + "observation": { + "sender": ["17e35b25d15d"], + "payloads": ["37921d9fdeb7"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "18e6a3ac6471", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "3571f351281f"] + } + }, + { + "id": "connected", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81"], + "payloads": ["37921d9fdeb7", "7c9498659f58"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a" + }, + "state": "a1f755a38636", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + }, + { + "id": "setup-prompted", + "observation": { + "sender": ["17e35b25d15d", "71d817ffdd81", "80a4af19f556"], + "payloads": ["37921d9fdeb7", "7c9498659f58", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "connect": "eb79a9b3682a", + "setup": "0ba2cee4b538" + }, + "state": "43ead075ce12", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "3571f351281f", + "1739575ac53e", + "fbfdbb919268", + "921f72d7827e", + "43fd3e2f4b53" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json new file mode 100644 index 00000000000..7a345df3eb3 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json @@ -0,0 +1,103 @@ +{ + "operation": "tasks.workspace-ssh-local", + "family": "tasks.workspace-ssh-local", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "af0623c2d106d2ed18ef9149d4990539f9ed82146ae091a872a3e1d792efeffe", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "41b0d115f434": { + "name": "workspaceDetectedAgentIds", + "value": { + "$rpc": "null" + } + }, + "7400f4eebe66": { + "agent": "claude", + "connecting": false, + "detected": ["codex", "claude"], + "setup": "unresolved", + "ssh": { + "$rpc": "null" + } + }, + "cb93b17470e8": { + "name": "preflight.detectAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectAgents" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": ["codex", "claude"] + } + } + }, + "cbb858a786ac": { + "name": "workspaceDetectedAgentIds", + "value": ["codex", "claude"] + }, + "cf32edc950ac": { + "name": "preflight.detectAgents#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" + }, + "ea709e13f0f0": { + "name": "workspaceAgentOverridden", + "value": false + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ed6189938d78": { + "name": "workspaceAgent", + "value": "claude" + } + }, + "recording": { + "scenario": "tw-workspace-ssh-local-agents", + "checkpoints": [ + { + "id": "local-agents-detected", + "observation": { + "sender": ["cb93b17470e8"], + "payloads": ["cf32edc950ac"], + "settlements": { + "mount": "eb79a9b3682a" + }, + "state": "7400f4eebe66", + "effects": ["ed6189938d78", "ea709e13f0f0", "41b0d115f434", "cbb858a786ac"] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json new file mode 100644 index 00000000000..0cd6feff928 --- /dev/null +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json @@ -0,0 +1,268 @@ +{ + "operation": "tasks.workspace-ssh", + "family": "tasks.workspace-ssh", + "namedDeltas": [], + "runnerVersion": 1, + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", + "lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571", + "recorderSha256": "5ec2faffa944097d2916e2e9e2259b9d0c68e364c5f865bafd8a9600faedbdc7", + "scenarioSha256": "3aa23f15da8fe9972e47c767db454b41750ca353ab10797082fde4514ffe9da0", + "platform": "darwin", + "scenarioVersion": 1, + "projectionVersion": 2, + "goldenFormatVersion": 4, + "values": { + "0adf11d42d1a": { + "name": "ssh.getState#1", + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.getState\",\"params\":{\"targetId\":\"ssh-1\"}}" + }, + "0f1cf505ed63": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connect Repo before creating a workspace.", + "isRpcDeliveryUnknown": false + } + }, + "15d9dbcfd2ce": { + "name": "repo.hooks#1", + "args": [ + { + "name": "method", + "value": "repo.hooks" + }, + { + "name": "params", + "value": { + "repo": "id:repo-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "hooks": { + "scripts": {} + } + } + } + } + }, + "1712c415bebf": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "decision": "inherit", + "kind": "decision" + } + }, + "25352a4de532": { + "name": "workspaceSshState", + "value": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "disconnected", + "targetId": "ssh-1" + } + }, + "28be8cfc5f01": { + "name": "preflight.detectRemoteAgents#1", + "args": [ + { + "name": "method", + "value": "preflight.detectRemoteAgents" + }, + { + "name": "params", + "value": { + "connectionId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "forbidden", + "message": "no access" + }, + "id": "frame-1", + "ok": false + } + } + }, + "37921d9fdeb7": { + "name": "preflight.detectRemoteAgents#1", + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectRemoteAgents\",\"params\":{\"connectionId\":\"ssh-1\"}}" + }, + "41b0d115f434": { + "name": "workspaceDetectedAgentIds", + "value": { + "$rpc": "null" + } + }, + "8ecc31aa9892": { + "name": "ssh.getState#1", + "args": [ + { + "name": "method", + "value": "ssh.getState" + }, + { + "name": "params", + "value": { + "targetId": "ssh-1" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "state": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "disconnected", + "targetId": "ssh-1" + } + } + } + } + }, + "9a9cd2877569": { + "agent": "claude", + "connecting": false, + "detected": [], + "setup": { + "decision": "inherit", + "kind": "decision" + }, + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "disconnected", + "targetId": "ssh-1" + } + }, + "9f152ed6e897": { + "name": "workspaceDetectedAgentIds", + "value": [] + }, + "d3698fc526a8": { + "agent": "claude", + "connecting": false, + "detected": [], + "setup": "unresolved", + "ssh": { + "error": { + "$rpc": "null" + }, + "reconnectAttempt": 0, + "status": "disconnected", + "targetId": "ssh-1" + } + }, + "ea709e13f0f0": { + "name": "workspaceAgentOverridden", + "value": false + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "ed6189938d78": { + "name": "workspaceAgent", + "value": "claude" + }, + "f0a9f62da106": { + "name": "repo.hooks#1", + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"repo.hooks\",\"params\":{\"repo\":\"id:repo-1\"}}" + } + }, + "recording": { + "scenario": "tw-workspace-ssh-not-ready", + "checkpoints": [ + { + "id": "ensure-rejected", + "observation": { + "sender": ["28be8cfc5f01", "8ecc31aa9892"], + "payloads": ["37921d9fdeb7", "0adf11d42d1a"], + "settlements": { + "mount": "eb79a9b3682a", + "ensure": "0f1cf505ed63" + }, + "state": "d3698fc526a8", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "25352a4de532" + ] + } + }, + { + "id": "no-setup-script", + "observation": { + "sender": ["28be8cfc5f01", "8ecc31aa9892", "15d9dbcfd2ce"], + "payloads": ["37921d9fdeb7", "0adf11d42d1a", "f0a9f62da106"], + "settlements": { + "mount": "eb79a9b3682a", + "ensure": "0f1cf505ed63", + "setup": "1712c415bebf" + }, + "state": "9a9cd2877569", + "effects": [ + "ed6189938d78", + "ea709e13f0f0", + "41b0d115f434", + "9f152ed6e897", + "25352a4de532" + ] + } + } + ] + } +} diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index 01652ad047a..1692faeaac4 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baseline": "e53f1557e1b5a6e26e85cfca390b34f7f8a7d26c", + "baseline": "50e752fc66a84638b0a8c1f4bdbbd5fdc708c3e9", "scenarios": [ { "id": "b1", @@ -1830,6 +1830,173 @@ } ] }, + { + "id": "settings-task-workspace-create-linear", + "operation": "settings.task-workspace-create", + "version": 1, + "family": "settings.task-workspace-create", + "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "action": "submit", + "id": "submit", + "args": { + "item": { + "key": "linear:1", + "provider": "linear", + "title": "Recorded issue", + "source": { + "identifier": "ORC-1", + "title": "Recorded issue", + "url": "https://linear.app/orca/issue/ORC-1" + } + } + } + }, + { + "checkpoint": "settings-pending" + }, + { + "complete": "settings.get#1", + "params": { + "$undefined": true + }, + "reply": { + "ok": true, + "result": { + "settings": { + "disabledTuiAgents": [], + "defaultTuiAgent": "codex" + } + } + } + }, + { + "complete": "worktree.create#1", + "params": { + "repo": "id:repo-1", + "name": "orc-1", + "displayName": "ORC-1 Recorded issue", + "displayNameKind": "generated", + "linkedLinearIssue": "ORC-1", + "setupDecision": "inherit", + "activate": true, + "startupDraft": "https://linear.app/orca/issue/ORC-1", + "createdWithAgent": "claude" + }, + "reply": { + "ok": true, + "result": { + "worktree": { + "id": "wt-1", + "displayName": "ORC-1 Recorded issue" + } + } + } + }, + { + "checkpoint": "created" + } + ] + }, + { + "id": "settings-task-workspace-create-pr-start-point", + "operation": "settings.task-workspace-create", + "version": 1, + "family": "settings.task-workspace-create", + "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "action": "submit", + "id": "submit", + "args": { + "item": { + "key": "github:7", + "provider": "github", + "title": "Recorded pull request", + "source": { + "type": "pr", + "repoId": "repo-1", + "number": 7, + "title": "Recorded pull request", + "url": "https://github.com/o/r/pull/7" + } + } + } + }, + { + "checkpoint": "settings-pending" + }, + { + "complete": "settings.get#1", + "params": { + "$undefined": true + }, + "reply": { + "ok": true, + "result": { + "settings": { + "disabledTuiAgents": [], + "defaultTuiAgent": "codex" + } + } + } + }, + { + "complete": "worktree.resolvePrBase#1", + "params": { + "repo": "id:repo-1", + "prNumber": 7 + }, + "reply": { + "ok": true, + "result": { + "baseBranch": "main" + } + } + }, + { + "checkpoint": "pr-base-resolved" + }, + { + "complete": "worktree.create#1", + "params": { + "repo": "id:repo-1", + "name": "pr-7", + "displayName": "Recorded pull request", + "displayNameKind": "generated", + "setupDecision": "inherit", + "activate": true, + "startupDraft": "https://github.com/o/r/pull/7", + "createdWithAgent": "claude", + "baseBranch": "main", + "linkedPR": 7 + }, + "reply": { + "ok": true, + "result": { + "worktree": { + "id": "wt-2" + }, + "warning": "shallow clone" + } + } + }, + { + "checkpoint": "created-from-pr-base" + } + ] + }, { "id": "settings-new-tab-refused", "operation": "settings.new-tab-agents", @@ -5137,6 +5304,1455 @@ "checkpoint": "settled" } ] + }, + { + "id": "tw-create-retry-created", + "operation": "tasks.worktree-create-retry", + "version": 1, + "family": "worktree.create-retry", + "sites": ["mobile/src/tasks/worktree-create-retry.ts"], + "schedules": [], + "steps": [ + { + "action": "create", + "id": "create" + }, + { + "complete": "worktree.create#1", + "params": { + "repo": "id:repo-1", + "name": "kestrel", + "clientMutationId": "mutation-1" + }, + "reply": { + "ok": true, + "result": { + "worktree": { + "id": "repo-1::/w", + "displayName": "kestrel" + } + } + } + }, + { + "checkpoint": "created" + } + ] + }, + { + "id": "tw-create-retry-warning-kept", + "operation": "tasks.worktree-create-retry", + "version": 1, + "family": "worktree.create-retry", + "sites": ["mobile/src/tasks/worktree-create-retry.ts"], + "schedules": [], + "steps": [ + { + "action": "create", + "id": "create" + }, + { + "complete": "worktree.create#1", + "params": { + "repo": "id:repo-1", + "name": "kestrel", + "clientMutationId": "mutation-1" + }, + "reply": { + "ok": true, + "result": { + "worktree": { + "id": "repo-1::/w" + }, + "warning": " startup terminal failed " + } + } + }, + { + "checkpoint": "created-with-warning" + } + ] + }, + { + "id": "tw-create-retry-name-collision", + "operation": "tasks.worktree-create-retry", + "version": 1, + "family": "worktree.create-retry", + "sites": ["mobile/src/tasks/worktree-create-retry.ts"], + "schedules": [], + "steps": [ + { + "action": "create", + "id": "create" + }, + { + "complete": "worktree.create#1", + "params": { + "repo": "id:repo-1", + "name": "kestrel", + "clientMutationId": "mutation-1" + }, + "reply": { + "ok": false, + "error": { + "code": "conflict", + "message": "Branch \"kestrel\" already exists." + } + } + }, + { + "checkpoint": "retrying" + }, + { + "complete": "worktree.create#2", + "params": { + "repo": "id:repo-1", + "name": "kestrel-2", + "clientMutationId": "mutation-2" + }, + "reply": { + "ok": true, + "result": { + "worktree": { + "id": "repo-1::/w2", + "displayName": "kestrel-2" + } + } + } + }, + { + "checkpoint": "created-suffixed" + } + ] + }, + { + "id": "tw-create-retry-unretryable-refusal", + "operation": "tasks.worktree-create-retry", + "version": 1, + "family": "worktree.create-retry", + "sites": ["mobile/src/tasks/worktree-create-retry.ts"], + "schedules": [], + "steps": [ + { + "action": "create", + "id": "create" + }, + { + "complete": "worktree.create#1", + "params": { + "repo": "id:repo-1", + "name": "kestrel", + "clientMutationId": "mutation-1" + }, + "reply": { + "ok": false, + "error": { + "code": "internal", + "message": "" + } + } + }, + { + "checkpoint": "refused-empty-message" + } + ] + }, + { + "id": "tw-create-retry-ambiguous-while-connected", + "operation": "tasks.worktree-create-retry", + "version": 1, + "family": "worktree.create-retry", + "sites": ["mobile/src/tasks/worktree-create-retry.ts"], + "schedules": [], + "steps": [ + { + "action": "create", + "id": "create" + }, + { + "complete": "worktree.create#1", + "params": { + "repo": "id:repo-1", + "name": "kestrel", + "clientMutationId": "mutation-1" + }, + "reject": { + "message": "Request timed out", + "deliveryUnknown": true + } + }, + { + "checkpoint": "unknown-not-failed" + } + ] + }, + { + "id": "tw-create-retry-ambiguous-after-drop", + "operation": "tasks.worktree-create-retry", + "version": 1, + "family": "worktree.create-retry", + "sites": ["mobile/src/tasks/worktree-create-retry.ts"], + "schedules": [], + "steps": [ + { + "action": "create", + "id": "create" + }, + { + "action": "disconnect", + "id": "drop" + }, + { + "checkpoint": "waiting-for-reconnect" + }, + { + "advance": 20000 + }, + { + "checkpoint": "replay-window-abandoned" + } + ] + }, + { + "id": "tw-create-retry-ambiguous-without-idempotency", + "operation": "tasks.worktree-create-retry", + "version": 1, + "family": "worktree.create-retry", + "sites": ["mobile/src/tasks/worktree-create-retry.ts"], + "schedules": [], + "steps": [ + { + "action": "create", + "id": "create", + "args": { + "idempotency": false + } + }, + { + "complete": "worktree.create#1", + "params": { + "repo": "id:repo-1", + "name": "kestrel" + }, + "reject": { + "message": "Connection lost", + "deliveryUnknown": true + } + }, + { + "checkpoint": "unstamped-create-is-not-replayed" + } + ] + }, + { + "id": "tw-capabilities-advertised", + "operation": "tasks.worktree-capabilities", + "version": 1, + "family": "worktree.runtime-capabilities", + "sites": ["mobile/src/tasks/worktree-create-capability.ts"], + "schedules": [], + "steps": [ + { + "action": "probe", + "id": "probe" + }, + { + "complete": "status.get#1", + "params": { + "$undefined": true + }, + "reply": { + "ok": true, + "result": { + "capabilities": ["mobile.tasks.v1", "worktree.create-idempotency.v1"], + "worktreeCreateIdempotency": { + "dedupeTtlMs": 45000 + }, + "platform": "linux" + } + } + }, + { + "checkpoint": "probed" + } + ] + }, + { + "id": "tw-capabilities-legacy-idempotency", + "operation": "tasks.worktree-capabilities", + "version": 1, + "family": "worktree.runtime-capabilities", + "sites": ["mobile/src/tasks/worktree-create-capability.ts"], + "schedules": [], + "steps": [ + { + "action": "probe", + "id": "probe" + }, + { + "complete": "status.get#1", + "params": { + "$undefined": true + }, + "reply": { + "ok": true, + "result": { + "capabilities": ["worktree.create-idempotency.v1"] + } + } + }, + { + "checkpoint": "legacy-host-window" + } + ] + }, + { + "id": "tw-capabilities-cutover-retried", + "operation": "tasks.worktree-capabilities", + "version": 1, + "family": "worktree.runtime-capabilities", + "sites": ["mobile/src/tasks/worktree-create-capability.ts"], + "schedules": [], + "steps": [ + { + "action": "probe", + "id": "probe" + }, + { + "action": "cutover", + "id": "migrate" + }, + { + "bind": "status-after-cutover", + "request": "status.get#2", + "params": { + "$undefined": true + } + }, + { + "checkpoint": "reprobing-after-cutover" + }, + { + "complete": "status-after-cutover", + "params": { + "$undefined": true + }, + "reply": { + "ok": true, + "result": { + "capabilities": ["mobile.tasks.v1"] + } + } + }, + { + "checkpoint": "probed-on-replacement" + } + ] + }, + { + "id": "tw-hosted-base-resolved", + "operation": "tasks.composer-hosted-base", + "version": 1, + "family": "worktree.hosted-base", + "sites": ["mobile/src/tasks/composer-source-base-resolve.ts"], + "schedules": [], + "steps": [ + { + "action": "pr-base", + "id": "pr" + }, + { + "complete": "worktree.resolvePrBase#1", + "params": { + "repo": "id:repo-1", + "prNumber": 12, + "headRefName": "feature" + }, + "reply": { + "ok": true, + "result": { + "baseBranch": "main", + "compareBaseRef": "origin/main" + } + } + }, + { + "checkpoint": "pr-base-resolved" + }, + { + "action": "mr-base", + "id": "mr" + }, + { + "complete": "worktree.resolveMrBase#1", + "params": { + "repo": "id:repo-1", + "mrIid": 7, + "sourceBranch": "feature" + }, + "reply": { + "ok": true, + "result": { + "baseBranch": "develop" + } + } + }, + { + "checkpoint": "mr-base-resolved" + } + ] + }, + { + "id": "tw-hosted-base-soft-error", + "operation": "tasks.composer-hosted-base", + "version": 1, + "family": "worktree.hosted-base", + "sites": ["mobile/src/tasks/composer-source-base-resolve.ts"], + "schedules": [], + "steps": [ + { + "action": "pr-base", + "id": "pr" + }, + { + "complete": "worktree.resolvePrBase#1", + "params": { + "repo": "id:repo-1", + "prNumber": 12, + "headRefName": "feature" + }, + "reply": { + "ok": true, + "result": { + "error": "pull request not found" + } + } + }, + { + "checkpoint": "in-band-error" + }, + { + "action": "mr-base", + "id": "mr" + }, + { + "complete": "worktree.resolveMrBase#1", + "params": { + "repo": "id:repo-1", + "mrIid": 7, + "sourceBranch": "feature" + }, + "reply": { + "ok": true, + "result": { + "error": "" + } + } + }, + { + "checkpoint": "in-band-empty-error" + } + ] + }, + { + "id": "tw-setup-hook-trust-approved", + "operation": "tasks.setup-hook-trust", + "version": 1, + "family": "worktree.setup-hook-trust", + "sites": ["mobile/src/tasks/setup-hook-trust.ts"], + "schedules": [], + "steps": [ + { + "action": "approve", + "id": "approve" + }, + { + "complete": "ui.set#1", + "params": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "contentHash": "hash-1", + "approvedAt": 1767225600000 + } + } + } + }, + "reply": { + "ok": true, + "result": { + "ok": true + } + } + }, + { + "checkpoint": "approved" + } + ] + }, + { + "id": "tw-setup-hook-trust-always", + "operation": "tasks.setup-hook-trust", + "version": 1, + "family": "worktree.setup-hook-trust", + "sites": ["mobile/src/tasks/setup-hook-trust.ts"], + "schedules": [], + "steps": [ + { + "action": "approve", + "id": "approve", + "args": { + "always": true + } + }, + { + "complete": "ui.set#1", + "params": { + "trustedOrcaHooks": { + "repo-1": { + "all": { + "approvedAt": 1767225600000 + } + } + } + }, + "reply": { + "ok": false, + "error": { + "code": "forbidden", + "message": "" + } + } + }, + { + "checkpoint": "refused-empty-message" + } + ] + }, + { + "id": "tw-smart-search-all-providers", + "operation": "tasks.smart-source-search", + "version": 1, + "family": "tasks.smart-source-search", + "sites": ["mobile/src/tasks/smart-source-search-requests.ts"], + "schedules": [], + "steps": [ + { + "action": "github", + "id": "github" + }, + { + "complete": "github.listWorkItems#1", + "params": { + "repo": "id:repo-1", + "limit": 36, + "query": "bug" + }, + "reply": { + "ok": true, + "result": { + "items": [ + { + "number": 1, + "title": "one" + } + ] + } + } + }, + { + "checkpoint": "github-items" + }, + { + "action": "gitlab", + "id": "gitlab" + }, + { + "complete": "gitlab.listWorkItems#1", + "params": { + "repo": "id:repo-1", + "state": "opened", + "page": 1, + "perPage": 50, + "query": "bug" + }, + "reply": { + "ok": true, + "result": { + "items": [ + { + "iid": 2, + "title": "two" + } + ], + "error": { + "type": "not_found", + "message": "missing" + } + } + } + }, + { + "checkpoint": "gitlab-items" + }, + { + "action": "linear", + "id": "linear" + }, + { + "complete": "linear.searchIssues#1", + "params": { + "query": "bug", + "limit": 50, + "workspaceId": "linear-workspace" + }, + "reply": { + "ok": true, + "result": { + "items": [ + { + "id": "issue-1" + } + ] + } + } + }, + { + "checkpoint": "linear-search" + }, + { + "action": "branches", + "id": "branches" + }, + { + "complete": "repo.searchRefs#1", + "params": { + "repo": "id:repo-1", + "query": "bug", + "limit": 20 + }, + "reply": { + "ok": true, + "result": { + "refs": ["main", "release"] + } + } + }, + { + "checkpoint": "branch-refs" + }, + { + "action": "linear", + "id": "linear-assigned", + "args": { + "query": " ", + "workspace": null + } + }, + { + "complete": "linear.listIssues#1", + "params": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$undefined": true + } + }, + "reply": { + "ok": true, + "result": [ + { + "id": "issue-3" + } + ] + } + }, + { + "checkpoint": "linear-assigned-listed" + } + ] + }, + { + "id": "tw-smart-search-linear-listed", + "operation": "tasks.smart-source-search", + "version": 1, + "family": "tasks.smart-source-search", + "sites": ["mobile/src/tasks/smart-source-search-requests.ts"], + "schedules": [], + "steps": [ + { + "action": "linear", + "id": "linear", + "args": { + "query": " ", + "workspace": null + } + }, + { + "complete": "linear.listIssues#1", + "params": { + "filter": "assigned", + "limit": 50, + "workspaceId": { + "$undefined": true + } + }, + "reply": { + "ok": true, + "result": [ + { + "id": "issue-2" + } + ] + } + }, + { + "checkpoint": "linear-assigned" + } + ] + }, + { + "id": "tw-smart-search-gitlab-provider-error", + "operation": "tasks.smart-source-search", + "version": 1, + "family": "tasks.smart-source-search", + "sites": ["mobile/src/tasks/smart-source-search-requests.ts"], + "schedules": [], + "steps": [ + { + "action": "gitlab", + "id": "gitlab" + }, + { + "complete": "gitlab.listWorkItems#1", + "params": { + "repo": "id:repo-1", + "state": "opened", + "page": 1, + "perPage": 50, + "query": "bug" + }, + "reply": { + "ok": true, + "result": { + "items": [], + "error": { + "type": "quota", + "message": "rate limited" + } + } + } + }, + { + "checkpoint": "in-band-provider-error" + }, + { + "action": "branches", + "id": "branches", + "args": { + "query": " main " + } + }, + { + "complete": "repo.searchRefs#1", + "params": { + "repo": "id:repo-1", + "query": "main", + "limit": 20 + }, + "reply": { + "ok": true, + "result": { + "refDetails": [ + { + "refName": "origin/main", + "localBranchName": "main" + } + ] + } + } + }, + { + "checkpoint": "branch-ref-details" + } + ] + }, + { + "id": "tw-paste-lookup-resolved", + "operation": "tasks.paste-lookup", + "version": 1, + "family": "tasks.paste-lookup", + "sites": ["mobile/src/tasks/smart-source-paste-intent.ts"], + "schedules": [], + "steps": [ + { + "action": "by-number", + "id": "by-number" + }, + { + "complete": "github.workItem#1", + "params": { + "repo": "id:repo-1", + "number": 12 + }, + "reply": { + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + }, + { + "checkpoint": "by-number" + }, + { + "action": "by-slug", + "id": "by-slug" + }, + { + "complete": "github.workItemByOwnerRepo#1", + "params": { + "repo": "id:repo-1", + "owner": "owner", + "ownerRepo": "repo", + "number": 12, + "type": "issue" + }, + "reply": { + "ok": true, + "result": { + "number": 12, + "title": "twelve" + } + } + }, + { + "checkpoint": "by-slug" + }, + { + "action": "gitlab-path", + "id": "gitlab-path" + }, + { + "complete": "gitlab.workItemByPath#1", + "params": { + "repo": "id:repo-1", + "host": "gitlab.com", + "path": "group/project", + "iid": 7, + "type": "issue" + }, + "reply": { + "ok": true, + "result": { + "iid": 7, + "title": "seven" + } + } + }, + { + "checkpoint": "gitlab-path" + }, + { + "action": "repo-slug", + "id": "repo-slug" + }, + { + "complete": "github.repoSlug#1", + "params": { + "repo": "id:repo-1" + }, + "reply": { + "ok": true, + "result": { + "owner": "owner", + "repo": "repo" + } + } + }, + { + "checkpoint": "repo-slug-matched" + } + ] + }, + { + "id": "tw-paste-lookup-slug-unsupported", + "operation": "tasks.paste-lookup", + "version": 1, + "family": "tasks.paste-lookup", + "sites": ["mobile/src/tasks/smart-source-paste-intent.ts"], + "schedules": [], + "steps": [ + { + "action": "repo-slug", + "id": "repo-slug" + }, + { + "complete": "github.repoSlug#1", + "params": { + "repo": "id:repo-1" + }, + "reply": { + "ok": false, + "error": { + "code": "method_not_found", + "message": "Unknown method" + } + } + }, + { + "checkpoint": "host-wide-probe-cached" + }, + { + "action": "repo-slug-again", + "id": "repo-slug-again" + }, + { + "checkpoint": "no-second-probe" + } + ] + }, + { + "id": "tw-paste-lookup-slug-refused", + "operation": "tasks.paste-lookup", + "version": 1, + "family": "tasks.paste-lookup", + "sites": ["mobile/src/tasks/smart-source-paste-intent.ts"], + "schedules": [], + "steps": [ + { + "action": "repo-slug", + "id": "repo-slug" + }, + { + "complete": "github.repoSlug#1", + "params": { + "repo": "id:repo-1" + }, + "reply": { + "ok": false, + "error": { + "code": "forbidden", + "message": "no access" + } + } + }, + { + "complete": "github.repoSlug#2", + "params": { + "repo": "id:repo-2" + }, + "reply": { + "ok": true, + "result": null + } + }, + { + "checkpoint": "refusal-is-per-repo" + } + ] + }, + { + "id": "tw-workspace-source-presets", + "operation": "tasks.workspace-source", + "version": 1, + "family": "tasks.workspace-source", + "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "complete": "repo.sparsePresets#1", + "params": { + "repo": "id:repo-1" + }, + "reply": { + "ok": true, + "result": { + "presets": [ + { + "id": "p1", + "name": "docs", + "directories": ["docs"] + } + ] + } + } + }, + { + "checkpoint": "presets-loaded" + }, + { + "action": "branch-query", + "id": "branch-query" + }, + { + "complete": "repo.searchRefs#1", + "params": { + "repo": "id:repo-1", + "query": "main", + "limit": 20 + }, + "reply": { + "ok": true, + "result": { + "refs": ["main"] + } + } + }, + { + "checkpoint": "branches-loaded" + } + ] + }, + { + "id": "tw-workspace-source-presets-refused", + "operation": "tasks.workspace-source", + "version": 1, + "family": "tasks.workspace-source", + "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "complete": "repo.sparsePresets#1", + "params": { + "repo": "id:repo-1" + }, + "reply": { + "ok": false, + "error": { + "code": "forbidden", + "message": "" + } + } + }, + { + "checkpoint": "presets-refused-empty-message" + } + ] + }, + { + "id": "tw-workspace-sparse-saved", + "operation": "tasks.workspace-sparse", + "version": 1, + "family": "tasks.workspace-sparse", + "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "complete": "ssh.getState#1", + "params": { + "targetId": "ssh-1" + }, + "reply": { + "ok": true, + "result": { + "state": { + "targetId": "ssh-1", + "status": "connected", + "error": null, + "reconnectAttempt": 0 + } + } + } + }, + { + "checkpoint": "ssh-state-read" + }, + { + "action": "save-preset", + "id": "save" + }, + { + "complete": "repo.saveSparsePreset#1", + "params": { + "repo": "id:repo-1", + "name": "docs", + "directories": ["docs"] + }, + "reply": { + "ok": true, + "result": { + "preset": { + "id": "p1", + "name": "docs", + "directories": ["docs"] + } + } + } + }, + { + "checkpoint": "preset-saved" + } + ] + }, + { + "id": "tw-workspace-sparse-missing-preset", + "operation": "tasks.workspace-sparse", + "version": 1, + "family": "tasks.workspace-sparse", + "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "complete": "ssh.getState#1", + "params": { + "targetId": "ssh-1" + }, + "reply": { + "ok": false, + "error": { + "code": "forbidden", + "message": "" + } + } + }, + { + "action": "save-preset", + "id": "save" + }, + { + "complete": "repo.saveSparsePreset#1", + "params": { + "repo": "id:repo-1", + "name": "docs", + "directories": ["docs"] + }, + "reply": { + "ok": true, + "result": {} + } + }, + { + "checkpoint": "saved-without-preset" + } + ] + }, + { + "id": "tw-workspace-ssh-connected", + "operation": "tasks.workspace-ssh", + "version": 1, + "family": "tasks.workspace-ssh", + "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "complete": "preflight.detectRemoteAgents#1", + "params": { + "connectionId": "ssh-1" + }, + "reply": { + "ok": true, + "result": ["codex"] + } + }, + { + "checkpoint": "agents-detected" + }, + { + "action": "connect", + "id": "connect" + }, + { + "complete": "ssh.connect#1", + "params": { + "targetId": "ssh-1" + }, + "reply": { + "ok": true, + "result": { + "state": { + "targetId": "ssh-1", + "status": "connected", + "error": null, + "reconnectAttempt": 0 + } + } + } + }, + { + "checkpoint": "connected" + }, + { + "action": "resolve-setup", + "id": "setup" + }, + { + "complete": "repo.hooks#1", + "params": { + "repo": "id:repo-1" + }, + "reply": { + "ok": true, + "result": { + "hooks": { + "scripts": { + "setup": " pnpm install " + } + }, + "setupRunPolicy": "ask", + "source": "repo", + "setupTrust": { + "contentHash": "hash-1", + "scriptContent": "pnpm install" + } + } + } + }, + { + "checkpoint": "setup-prompted" + } + ] + }, + { + "id": "tw-workspace-ssh-not-ready", + "operation": "tasks.workspace-ssh", + "version": 1, + "family": "tasks.workspace-ssh", + "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "complete": "preflight.detectRemoteAgents#1", + "params": { + "connectionId": "ssh-1" + }, + "reply": { + "ok": false, + "error": { + "code": "forbidden", + "message": "no access" + } + } + }, + { + "action": "ensure-ready", + "id": "ensure" + }, + { + "complete": "ssh.getState#1", + "params": { + "targetId": "ssh-1" + }, + "reply": { + "ok": true, + "result": { + "state": { + "targetId": "ssh-1", + "status": "disconnected", + "error": null, + "reconnectAttempt": 0 + } + } + } + }, + { + "checkpoint": "ensure-rejected" + }, + { + "action": "resolve-setup", + "id": "setup" + }, + { + "complete": "repo.hooks#1", + "params": { + "repo": "id:repo-1" + }, + "reply": { + "ok": true, + "result": { + "hooks": { + "scripts": {} + } + } + } + }, + { + "checkpoint": "no-setup-script" + } + ] + }, + { + "id": "tw-workspace-ssh-connect-refused", + "operation": "tasks.workspace-ssh", + "version": 1, + "family": "tasks.workspace-ssh", + "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "complete": "preflight.detectRemoteAgents#1", + "params": { + "connectionId": "ssh-1" + }, + "reject": { + "message": "Connection lost", + "deliveryUnknown": true + } + }, + { + "action": "connect", + "id": "connect" + }, + { + "complete": "ssh.connect#1", + "params": { + "targetId": "ssh-1" + }, + "reply": { + "ok": false, + "error": { + "code": "ssh_failed", + "message": "" + } + } + }, + { + "checkpoint": "connect-refused-empty-message" + }, + { + "action": "resolve-setup", + "id": "setup" + }, + { + "complete": "repo.hooks#1", + "params": { + "repo": "id:repo-1" + }, + "reply": { + "ok": true, + "result": { + "hooks": { + "scripts": { + "setup": "pnpm i" + } + }, + "setupRunPolicy": "never" + } + } + }, + { + "checkpoint": "setup-skipped" + } + ] + }, + { + "id": "tw-workspace-ssh-local-agents", + "operation": "tasks.workspace-ssh-local", + "version": 1, + "family": "tasks.workspace-ssh-local", + "sites": ["mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "complete": "preflight.detectAgents#1", + "params": { + "$undefined": true + }, + "reply": { + "ok": true, + "result": ["codex", "claude"] + } + }, + { + "checkpoint": "local-agents-detected" + } + ] + }, + { + "id": "tw-task-preferences-resume-write", + "operation": "settings.task-preferences", + "version": 1, + "family": "settings-best-effort", + "sites": ["mobile/src/tasks/use-mobile-tasks-client-settings-actions.tsx"], + "schedules": [], + "steps": [ + { + "action": "mount", + "id": "mount" + }, + { + "action": "resume", + "id": "resume" + }, + { + "complete": "ui.set#1", + "params": { + "taskResumeState": { + "githubItemsPreset": "issues" + } + }, + "reply": { + "ok": false, + "error": { + "code": "forbidden", + "message": "no access" + } + } + }, + { + "checkpoint": "best-effort-resume-write" + }, + { + "action": "trust", + "id": "trust" + }, + { + "complete": "ui.set#2", + "params": { + "trustedOrcaHooks": { + "repo-1": { + "setup": { + "contentHash": "hash-1", + "approvedAt": 1767225600000 + } + } + } + }, + "reply": { + "ok": false, + "error": { + "code": "forbidden", + "message": "" + } + } + }, + { + "checkpoint": "awaited-trust-write-refused" + } + ] } ] } diff --git a/mobile/src/source-control/mobile-git-read-operations.ts b/mobile/src/source-control/mobile-git-read-operations.ts index e611abe982c..3b815f3eb5b 100644 --- a/mobile/src/source-control/mobile-git-read-operations.ts +++ b/mobile/src/source-control/mobile-git-read-operations.ts @@ -1,6 +1,9 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' import type { RpcCompatibleReader } from '../transport/rpc-operation-contract' -import { rpcPayloadMember, rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' +import { + rpcUncheckedMemberReader, + rpcUncheckedPayloadReader +} from '../transport/rpc-reader-payload' import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc' import type { MobileGitStatusResult } from './mobile-git-status' @@ -58,27 +61,18 @@ export const gitHistoryRead = bindDeferredRpcOperation( }) ) -const commitCompareEntriesReader: RpcCompatibleReader< - unknown, - 'commit-compare-entries', - unknown -> = (raw) => ({ - compatible: true, - variant: 'commit-compare-entries', - // Keeps the property-read exception the expanded-commit list already relies on: a null result - // throws inside the load, which is what leaves an already-loaded file list alone. - value: rpcPayloadMember(raw, 'entries'), - salvage: { droppedPaths: [], droppedCount: 0 } -}) - -/** A refused compare leaves the row's file list untouched, so refusal is a skip, not a throw. */ +/** + * A refused compare leaves the row's file list untouched, so refusal is a skip, not a throw. The + * member read keeps the property-read exception a null result throws, which is what leaves an + * already-loaded file list alone. + */ export const gitCommitCompareRead = bindDeferredRpcOperation( defineRpcOperation({ name: 'git.commit-compare-entries-or-skip', method: 'git.commitCompare', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: commitCompareEntriesReader + read: rpcUncheckedMemberReader('commit-compare-entries', 'entries') }) ) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts index 3c38ac37447..ea3c827c3b9 100644 --- a/mobile/src/tasks/blank-workspace-create.ts +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -4,6 +4,7 @@ import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktr import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy' import { agentLaunchCreateFields, + type WorkspaceCreateParams, type WorkspaceCreateSetupDecision } from './workspace-create-params' @@ -28,7 +29,7 @@ export async function createBlankWorkspace(args: { nameWasGenerated: args.nameWasGenerated, worktreeCreateIdempotency: args.worktreeCreateIdempotency, buildParams: (name) => { - const params: Record = { + const params: WorkspaceCreateParams = { repo: `id:${args.repoId}`, setupDecision: args.setupDecision, name, diff --git a/mobile/src/tasks/composer-source-base-resolve.ts b/mobile/src/tasks/composer-source-base-resolve.ts index 423c3b05294..40419993970 100644 --- a/mobile/src/tasks/composer-source-base-resolve.ts +++ b/mobile/src/tasks/composer-source-base-resolve.ts @@ -1,6 +1,6 @@ import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' import type { GitHubPrStartPoint } from '../../../src/shared/worktree/types' +import { worktreeMrBaseResolve, worktreePrBaseResolve } from './mobile-workspace-create-operations' // The resolved start point for a linked PR/MR: the base branch to create from // plus the optional review-compare ref, push target, and exact branch name. @@ -23,8 +23,8 @@ export async function resolveComposerPrBase(args: { isCrossRepository?: boolean }): Promise { const { client, repoId, prNumber, headRefName, baseRefName, isCrossRepository } = args - const response = await client.sendRequest( - 'worktree.resolvePrBase', + const reply = await worktreePrBaseResolve.request( + client, { repo: `id:${repoId}`, prNumber, @@ -34,10 +34,8 @@ export async function resolveComposerPrBase(args: { }, { timeoutMs: 30_000 } ) - if (!response.ok) { - throw new Error(response.error.message) - } - const result = (response as RpcSuccess).result as GitHubPrStartPoint | { error: string } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = worktreePrBaseResolve.interpret(reply) as GitHubPrStartPoint | { error: string } if ('error' in result) { throw new Error(result.error) } @@ -54,8 +52,8 @@ export async function resolveComposerMrBase(args: { isCrossRepository?: boolean }): Promise { const { client, repoId, mrIid, sourceBranch, targetBranch, isCrossRepository } = args - const response = await client.sendRequest( - 'worktree.resolveMrBase', + const reply = await worktreeMrBaseResolve.request( + client, { repo: `id:${repoId}`, mrIid, @@ -65,10 +63,8 @@ export async function resolveComposerMrBase(args: { }, { timeoutMs: 30_000 } ) - if (!response.ok) { - throw new Error(response.error.message) - } - const result = (response as RpcSuccess).result as HostedBaseResult + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = worktreeMrBaseResolve.interpret(reply) as HostedBaseResult if ('error' in result) { throw new Error(result.error) } diff --git a/mobile/src/tasks/mobile-task-runtime-operations.ts b/mobile/src/tasks/mobile-task-runtime-operations.ts new file mode 100644 index 00000000000..c69651af666 --- /dev/null +++ b/mobile/src/tasks/mobile-task-runtime-operations.ts @@ -0,0 +1,87 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { + rpcUncheckedMemberReader, + rpcUncheckedPayloadReader +} from '../transport/rpc-reader-payload' + +// What the Tasks screen reads once per host to hydrate, and the preferences it writes back. + +/** + * status.get read for task hydration, the first of two policies on this method. A refused status + * stops hydration with the host's own message; the create-time probe in + * mobile-workspace-create-operations.ts degrades instead. One reader serves both. + */ +export const taskRuntimeStatusRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'status.task-runtime', + method: 'status.get', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('runtime-status') + }) +) + +/** + * Persisted UI state, read at the hydration barrier alongside preflight and Linear status. A + * refused read leaves the screen on its defaults rather than failing hydration, so it is a skip. + */ +export const taskUiStateRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'ui.task-state-or-skip', + method: 'ui.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedMemberReader('ui-state-member', 'ui') + }) +) + +/** Whether `glab` is installed, which gates the GitLab provider. Advisory, so refusal skips. */ +export const taskPreflightRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'preflight.task-tooling-or-skip', + method: 'preflight.check', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('task-preflight') + }) +) + +/** Whether Linear is connected. Also advisory: an unanswered probe means "not connected". */ +export const taskLinearStatusRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.task-status-or-skip', + method: 'linear.status', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('linear-status') + }) +) + +/** + * Writing persisted UI state. Two of its three call sites await it and surface the host's refusal + * message; the third is fire-and-forget and never interprets the reply, so no acceptance applies + * there. The payload is unread either way. + */ +export const taskUiStateWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'ui.set-task-state', + method: 'ui.set', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('ui-state-written') + }) +) + +/** + * Writing a host setting from the Tasks screen. Every call site is best-effort — the in-memory + * picker already reflects the change — so a refusal is a skip, and none of them reads the payload. + */ +export const taskSettingsWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'settings.update-task-preference-or-skip', + method: 'settings.update', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('setting-written') + }) +) diff --git a/mobile/src/tasks/mobile-task-source-search-operations.ts b/mobile/src/tasks/mobile-task-source-search-operations.ts new file mode 100644 index 00000000000..7460c42bfad --- /dev/null +++ b/mobile/src/tasks/mobile-task-source-search-operations.ts @@ -0,0 +1,100 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import type { RpcCompatibleReader } from '../transport/rpc-operation-contract' +import { rpcReadUnchecked, rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' +import { extractLinearIssueReadItems } from './linear-mobile-issue-read' + +// The Smart workspace-source picker's provider reads: per-repo search, and the single-item lookups +// a pasted link or number resolves to. Provider-specific fallbacks stay at their own call sites. + +export const githubWorkItemSearchRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.work-item-search', + method: 'github.listWorkItems', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-work-items') + }) +) + +/** GitLab answers in-band too: an accepted reply can carry a provider `error` the caller raises. */ +export const gitlabWorkItemSearchRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.work-item-search', + method: 'gitlab.listWorkItems', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-work-items') + }) +) + +// Linear replies either as a bare array or as an `{ items }` envelope, and the picker has always +// accepted both through this projection. Two operations share it because the empty-query path asks +// a different method, not because the two answers differ. +const linearIssueReader: RpcCompatibleReader = (raw) => + rpcReadUnchecked('linear-issues', extractLinearIssueReadItems(raw)) + +export const linearIssueSearchRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.issue-search', + method: 'linear.searchIssues', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: linearIssueReader + }) +) + +export const linearAssignedIssueListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'linear.assigned-issue-list', + method: 'linear.listIssues', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: linearIssueReader + }) +) + +/** + * A repo's owner/repo slug, asked per repo so a pasted cross-repo URL can be matched without + * assuming github.com syntax. A refusal means "this repo cannot answer", which the caller caches + * as no slug rather than failing the paste — so refusal is a skip. The caller still reads the + * refusal code directly, because `method_not_found` is host-wide and retires the whole probe. + */ +export const githubRepoSlugRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.repo-slug-or-skip', + method: 'github.repoSlug', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('repo-slug') + }) +) + +export const githubWorkItemByNumberRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.work-item-by-number', + method: 'github.workItem', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-work-item') + }) +) + +export const githubWorkItemBySlugRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'github.work-item-by-owner-repo', + method: 'github.workItemByOwnerRepo', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('github-work-item') + }) +) + +export const gitlabWorkItemByPathRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'gitlab.work-item-by-path', + method: 'gitlab.workItemByPath', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('gitlab-work-item') + }) +) diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts index 111b119db8e..dcd84ba1656 100644 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts @@ -16,12 +16,17 @@ const hash = (parts: string[] | string): string => .update(Array.isArray(parts) ? parts.join('\n') : parts) .digest('hex') -// Bound settings requests change source signatures; their behavior is covered by settings-read-operations.test.ts. -const SETTINGS_RPC_SCREEN_HOOKS = 'fb2d873e06001fbae7cee78d079b3df9dc2eedb56ab2f03c7ffb431bc8666191' +// Bound workspace-creation requests change source signatures the same way bound settings requests +// did: the method string and the envelope read leave the screen and an operation name arrives. The +// behaviour they used to pin is pinned by the recordings in mobile/rpc-foundation/goldens instead, +// which did not move. Statement, declaration, render and style counts are unchanged; `semantics` +// loses exactly the 22 `rpc:` signatures and 22 method literals the migration deleted. +const WORKSPACE_RPC_SCREEN_HOOKS = + '26ed5700089a9de13ea984274eb10ddea62f72b28135992514e3c16ef8e47e30' const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' -const SETTINGS_RPC_STATEMENTS = '1c99d6382f74c37c0ff896dfa634fb503c9fe8062e2280328d0b82f79f658fdb' +const WORKSPACE_RPC_STATEMENTS = 'c25179660e089fd602b06e8c235e5f92d62e63d6d4add4c33ff89a4b5f9493cc' const MAIN_REBASED_DECLARATIONS = '6ad0397123e59fc1047a14049c86ff31d81723673a7a7f5c41677471aec58415' -const SETTINGS_RPC_SEMANTICS = '2431b1c07dfe9a9c94f5d3f4e91415ed99bd9e1bce3794f8bd5f094a29134d77' +const WORKSPACE_RPC_SEMANTICS = '7a00e700fe7293df9b5b68470185197c56a27007d89038a183153b29326113c0' const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a' const PRE_REFACTOR_RENDER_TREE = '2111145136b1e4fbca150d4792d735a90e992488e9934cfc1a8b8f3be981f39f' @@ -29,7 +34,7 @@ describe('Mobile Tasks refactor parity', () => { it('preserves recursively flattened hook and dependency order', () => { const screenHooks = readFlattenedMobileTasksHookSignatures('MobileTasksScreen') expect(screenHooks).toHaveLength(350) - expect(hash(screenHooks)).toBe(SETTINGS_RPC_SCREEN_HOOKS) + expect(hash(screenHooks)).toBe(WORKSPACE_RPC_SCREEN_HOOKS) const diffHooks = readFlattenedMobileTasksHookSignatures('GitHubPrFileDiff') expect(diffHooks).toHaveLength(3) @@ -39,7 +44,7 @@ describe('Mobile Tasks refactor parity', () => { it('preserves every screen statement in execution order', () => { const statements = readFlattenedMobileTasksCoreStatements() expect(statements).toHaveLength(417) - expect(hash(statements)).toBe(SETTINGS_RPC_STATEMENTS) + expect(hash(statements)).toBe(WORKSPACE_RPC_STATEMENTS) }) it('preserves every moved top-level declaration', () => { @@ -50,8 +55,8 @@ describe('Mobile Tasks refactor parity', () => { it('preserves RPC calls, runtime strings, and JSX host signatures', () => { const semantics = readMobileTasksSemanticSource() - expect(semantics.split('\n')).toHaveLength(3_496) - expect(hash(semantics)).toBe(SETTINGS_RPC_SEMANTICS) + expect(semantics.split('\n')).toHaveLength(3_452) + expect(hash(semantics)).toBe(WORKSPACE_RPC_SEMANTICS) }) it('preserves render expressions and event handlers in tree order', () => { diff --git a/mobile/src/tasks/mobile-workspace-create-operations.ts b/mobile/src/tasks/mobile-workspace-create-operations.ts new file mode 100644 index 00000000000..55cad2f373f --- /dev/null +++ b/mobile/src/tasks/mobile-workspace-create-operations.ts @@ -0,0 +1,64 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' + +// Creating a workspace from a task. Every reply here is one the call site only re-typed, so the +// readers are unchecked: moving a shape check in would be a validation change, not a migration. + +/** + * worktree.create. A lost reply is *unknown*, never failed — `worktree-create-retry.ts` replays on + * the same clientMutationId — so this operation never interprets a transport rejection: `request` + * hands back the transport promise itself and the delivery-unknown mark reaches the retry loop on + * the original rejection object. + */ +export const worktreeCreateRun = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'worktree.create', + method: 'worktree.create', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('created-worktree') + }) +) + +/** + * The start point for a workspace created from a linked pull request. Refusal throws the host's + * message; an accepted reply can still carry a soft `{ error }` the caller raises itself. + */ +export const worktreePrBaseResolve = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'worktree.resolve-pr-base', + method: 'worktree.resolvePrBase', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('pr-start-point') + }) +) + +/** The GitLab merge-request equivalent; same acceptance, same soft-error convention. */ +export const worktreeMrBaseResolve = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'worktree.resolve-mr-base', + method: 'worktree.resolveMrBase', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('mr-start-point') + }) +) + +/** + * status.get read for create-time capabilities, the second of two policies on this method. + * + * Both policies named because the two callers disagree about what a refused status means: the + * Tasks screen cannot hydrate without it and surfaces the host's message (`taskRuntimeStatusRead`), + * while create-time capability probing degrades to "no capabilities" and creates anyway, so here a + * refusal is a skip. One reader serves both — the payload is unchecked in each. + */ +export const worktreeCreateCapabilityRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'status.create-capabilities-or-skip', + method: 'status.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('runtime-status') + }) +) diff --git a/mobile/src/tasks/mobile-workspace-source-operations.ts b/mobile/src/tasks/mobile-workspace-source-operations.ts new file mode 100644 index 00000000000..3126682c9a1 --- /dev/null +++ b/mobile/src/tasks/mobile-workspace-source-operations.ts @@ -0,0 +1,101 @@ +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { + rpcUncheckedMemberReader, + rpcUncheckedPayloadReader +} from '../transport/rpc-reader-payload' + +// The repo and SSH reads the workspace-create drawer runs: connection state, agent detection, +// repo-owned setup hooks, sparse presets and base-branch search. + +const sshConnectionStateReader = rpcUncheckedMemberReader('ssh-connection-state', 'state') + +/** Connecting an SSH repo before create. The reply's only read field is `state`. */ +export const sshRepoConnectRun = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'ssh.connect-repo', + method: 'ssh.connect', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: sshConnectionStateReader + }) +) + +/** The same field, read by the drawer's state effect and by the pre-create readiness check. */ +export const sshRepoStateRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'ssh.repo-state', + method: 'ssh.getState', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: sshConnectionStateReader + }) +) + +// Agent detection is advisory: a refused or failed probe leaves the drawer with an empty set and +// the runtime still validates availability before spawning, so refusal is a skip. +export const remoteAgentDetectionRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'preflight.detect-remote-agents-or-skip', + method: 'preflight.detectRemoteAgents', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('detected-agent-ids') + }) +) + +/** The local host's agents, for a repo with no SSH connection. */ +export const localAgentDetectionRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'preflight.detect-agents-or-skip', + method: 'preflight.detectAgents', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('detected-agent-ids') + }) +) + +/** The repo's orca.yaml hooks, which decide whether create must ask before running setup. */ +export const repoSetupHooksRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'repo.setup-hooks', + method: 'repo.hooks', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('repo-hooks') + }) +) + +export const repoSparsePresetListRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'repo.sparse-preset-list', + method: 'repo.sparsePresets', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedMemberReader('sparse-presets', 'presets') + }) +) + +export const repoSparsePresetSaveRun = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'repo.save-sparse-preset', + method: 'repo.saveSparsePreset', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedMemberReader('saved-sparse-preset', 'preset') + }) +) + +/** + * Base-branch search. The payload is unchecked: both callers — the drawer's picker effect and the + * Smart source picker — spell their own `refDetails ?? refs.map(...)` fallback, and reproducing + * that in the reader would need a type assertion the operation fence rightly bans. + */ +export const repoBaseRefSearchRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'repo.base-ref-search', + method: 'repo.searchRefs', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcUncheckedPayloadReader('base-ref-search') + }) +) diff --git a/mobile/src/tasks/setup-hook-trust.ts b/mobile/src/tasks/setup-hook-trust.ts index 72381393989..e6cb492e617 100644 --- a/mobile/src/tasks/setup-hook-trust.ts +++ b/mobile/src/tasks/setup-hook-trust.ts @@ -1,5 +1,6 @@ import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' import type { RpcClient } from '../transport/rpc-client' +import { taskUiStateWrite } from './mobile-task-runtime-operations' export type SetupHookTrust = { contentHash: string @@ -45,10 +46,9 @@ export async function persistSetupHookTrustApproval(args: { alwaysTrust: boolean }): Promise { const next = trustedOrcaHooksWithSetupApproval(args) - const response = await args.client.sendRequest('ui.set', { trustedOrcaHooks: next }) - if (!response.ok) { - throw new Error(response.error.message) - } + taskUiStateWrite.interpret( + await taskUiStateWrite.request(args.client, { trustedOrcaHooks: next }) + ) return next } diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts index 21afd4157de..87715eab039 100644 --- a/mobile/src/tasks/smart-source-paste-intent.ts +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -9,7 +9,13 @@ import { import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitlab-links' import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' +import { isMethodNotFoundRefusal } from '../transport/rpc-acceptance-policies' +import { + githubRepoSlugRead, + githubWorkItemByNumberRead, + githubWorkItemBySlugRead, + gitlabWorkItemByPathRead +} from './mobile-task-source-search-operations' import { githubRepoIdentityKey } from '../../../src/shared/github/repository-identity-key' // A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived @@ -108,14 +114,18 @@ export async function findRepoMatchingSlugForPaste( let resolved = cache.get(repo.id) if (!cache.has(repo.id)) { try { - const response = await client.sendRequest('github.repoSlug', { repo: `id:${repo.id}` }) - if (!response.ok && response.error.code === 'method_not_found') { + const reply = await githubRepoSlugRead.request(client, { repo: `id:${repo.id}` }) + // Why the raw refusal: a missing method retires the probe host-wide, and the acceptance + // policy reports only that the reply was refused, not with which code. + if (isMethodNotFoundRefusal(reply)) { // Why: RPC availability is host-wide; avoid repeating an unsupported // probe for every repo or on the next paste attempt. repos.forEach((candidate) => cache.set(candidate.id, null)) return null } - resolved = response.ok ? ((response as RpcSuccess).result as RepoSlug | null) : null + const slug = githubRepoSlugRead.interpret(reply) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + resolved = slug.accepted ? (slug.value as RepoSlug | null) : null } catch { resolved = null } @@ -133,11 +143,12 @@ export async function lookupGitHubItemByNumber( repoId: string, number: number ): Promise { - const response = await client.sendRequest('github.workItem', { repo: `id:${repoId}`, number }) - if (!response.ok) { - throw new Error(response.error.message) - } - const item = (response as RpcSuccess).result as GitHubWorkItem | null + const reply = await githubWorkItemByNumberRead.request(client, { + repo: `id:${repoId}`, + number + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const item = githubWorkItemByNumberRead.interpret(reply) as GitHubWorkItem | null return item ? { ...item, repoId } : null } @@ -148,7 +159,7 @@ export async function lookupGitHubItemByOwnerRepo( number: number, type: 'issue' | 'pr' ): Promise { - const response = await client.sendRequest('github.workItemByOwnerRepo', { + const reply = await githubWorkItemBySlugRead.request(client, { repo: `id:${repoId}`, owner: slug.owner, ownerRepo: slug.repo, @@ -156,10 +167,8 @@ export async function lookupGitHubItemByOwnerRepo( number, type }) - if (!response.ok) { - throw new Error(response.error.message) - } - const item = (response as RpcSuccess).result as GitHubWorkItem | null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const item = githubWorkItemBySlugRead.interpret(reply) as GitHubWorkItem | null return item ? { ...item, repoId } : null } @@ -168,16 +177,14 @@ export async function lookupGitLabItemByPath( repoId: string, link: NonNullable> ): Promise { - const response = await client.sendRequest('gitlab.workItemByPath', { + const reply = await gitlabWorkItemByPathRead.request(client, { repo: `id:${repoId}`, host: link.slug.host, path: link.slug.path, iid: link.number, type: link.type }) - if (!response.ok) { - throw new Error(response.error.message) - } - const item = (response as RpcSuccess).result as GitLabWorkItem | null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const item = gitlabWorkItemByPathRead.interpret(reply) as GitLabWorkItem | null return item ? { ...item, repoId } : null } diff --git a/mobile/src/tasks/smart-source-search-requests.ts b/mobile/src/tasks/smart-source-search-requests.ts index 876e2ef1a20..432e6f1c6bc 100644 --- a/mobile/src/tasks/smart-source-search-requests.ts +++ b/mobile/src/tasks/smart-source-search-requests.ts @@ -3,8 +3,13 @@ import type { GitLabWorkItem } from '../../../src/shared/gitlab-types' import type { LinearIssue } from '../../../src/shared/linear/issue-types' import type { BaseRefSearchResult } from '../../../src/shared/repo-types' import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' -import { extractLinearIssueReadItems } from './linear-mobile-issue-read' +import { repoBaseRefSearchRead } from './mobile-workspace-source-operations' +import { + githubWorkItemSearchRead, + gitlabWorkItemSearchRead, + linearAssignedIssueListRead, + linearIssueSearchRead +} from './mobile-task-source-search-operations' import { PER_REPO_FETCH_LIMIT } from './mobile-work-items' import type { MrStateFilter } from './mobile-composer-source-types' @@ -26,15 +31,13 @@ export async function searchGitHubItems( repoId: string, query: string ): Promise { - const response = await client.sendRequest('github.listWorkItems', { + const reply = await githubWorkItemSearchRead.request(client, { repo: `id:${repoId}`, limit: PER_REPO_FETCH_LIMIT, query: scopeGitHubQuery(query) }) - if (!response.ok) { - throw new Error(response.error.message) - } - const envelope = (response as RpcSuccess).result as { items: GitHubWorkItem[] } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const envelope = githubWorkItemSearchRead.interpret(reply) as { items: GitHubWorkItem[] } // Stamp repoId so the shared row builder + create flow can attribute each item // to the searched repo (the runtime omits it, like the desktop fetcher). return (envelope.items ?? []).map((item) => ({ ...item, repoId })) @@ -46,17 +49,15 @@ export async function searchGitLabItems( query: string, state: MrStateFilter ): Promise { - const response = await client.sendRequest('gitlab.listWorkItems', { + const reply = await gitlabWorkItemSearchRead.request(client, { repo: `id:${repoId}`, state, page: 1, perPage: GITLAB_PER_PAGE, query: query.trim() || undefined }) - if (!response.ok) { - throw new Error(response.error.message) - } - const envelope = (response as RpcSuccess).result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const envelope = gitlabWorkItemSearchRead.interpret(reply) as { items: GitLabWorkItem[] error?: { type?: string; message: string } } @@ -72,25 +73,27 @@ export async function searchLinearIssues( linearWorkspaceId: string | null | undefined ): Promise { const trimmed = query.trim() - const response = trimmed - ? await client.sendRequest('linear.searchIssues', { - query: trimmed, - limit: LINEAR_LIMIT, - workspaceId: linearWorkspaceId ?? undefined - }) - : await client.sendRequest('linear.listIssues', { - // Empty query lists the viewer's assigned issues, matching desktop's - // Smart picker default (SmartWorkspaceNameField uses listLinearIssues('assigned')). - filter: 'assigned', - limit: LINEAR_LIMIT, - workspaceId: linearWorkspaceId ?? undefined - }) - if (!response.ok) { - throw new Error(response.error.message) - } - // extractLinearIssueReadItems yields the mobile issue-read shape; the fields the - // row builder/create flow read (id/identifier/title/url/state/team) are a subset. - return extractLinearIssueReadItems((response as RpcSuccess).result) as unknown as LinearIssue[] + // The reader yields the mobile issue-read shape; the fields the row builder/create flow read + // (id/identifier/title/url/state/team) are a subset. + const issues = trimmed + ? linearIssueSearchRead.interpret( + await linearIssueSearchRead.request(client, { + query: trimmed, + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + ) + : linearAssignedIssueListRead.interpret( + await linearAssignedIssueListRead.request(client, { + // Empty query lists the viewer's assigned issues, matching desktop's + // Smart picker default (SmartWorkspaceNameField uses listLinearIssues('assigned')). + filter: 'assigned', + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + return issues as LinearIssue[] } export async function searchBranches( @@ -98,15 +101,13 @@ export async function searchBranches( repoId: string, query: string ): Promise { - const response = await client.sendRequest( - 'repo.searchRefs', + const reply = await repoBaseRefSearchRead.request( + client, { repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT }, { timeoutMs: 30_000 } ) - if (!response.ok) { - throw new Error(response.error.message) - } - const result = (response as RpcSuccess).result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = repoBaseRefSearchRead.interpret(reply) as { refDetails?: BaseRefSearchResult[] refs?: string[] } diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts index 6e666005ac3..30dd16f89fb 100644 --- a/mobile/src/tasks/source-workspace-create.ts +++ b/mobile/src/tasks/source-workspace-create.ts @@ -9,6 +9,7 @@ import type { WorkspaceAgentChoice } from './workspace-agent-selection' import { agentLaunchCreateFields, buildTaskWorkspaceCreateParams, + type WorkspaceCreateParams, type WorkspaceCreateSetupDecision, type WorkspaceCreateTaskItem } from './workspace-create-params' @@ -167,7 +168,7 @@ async function createBranchWorkspace(args: { const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice const comment = note?.trim() const manualDisplayName = nameIsAutoManaged === true ? undefined : workspaceName?.trim() - const applyCommon = (params: Record): Record => { + const applyCommon = (params: WorkspaceCreateParams): WorkspaceCreateParams => { Object.assign(params, agentLaunchCreateFields(createdWithAgentId)) if (comment) { params.comment = comment @@ -213,7 +214,7 @@ async function createBranchWorkspace(args: { baseName, worktreeCreateIdempotency: args.worktreeCreateIdempotency, buildParams: (candidate) => { - const params: Record = { + const params: WorkspaceCreateParams = { repo: `id:${targetRepoId}`, name: candidate, setupDecision, @@ -263,7 +264,7 @@ async function createNewBranchWorkspace(args: { baseName: selection.branchName, worktreeCreateIdempotency: args.worktreeCreateIdempotency, buildParams: (candidate) => { - const params: Record = { + const params: WorkspaceCreateParams = { repo: `id:${targetRepoId}`, name: candidate, setupDecision, diff --git a/mobile/src/tasks/use-mobile-tasks-client-settings-actions.tsx b/mobile/src/tasks/use-mobile-tasks-client-settings-actions.tsx index fe048e7a5b7..ade7b46cb8f 100644 --- a/mobile/src/tasks/use-mobile-tasks-client-settings-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-client-settings-actions.tsx @@ -7,12 +7,8 @@ import { useLayoutEffect, useState } from './mobile-tasks-dependencies' -import { - type GitHubPreset, - type RepoSummary, - type TaskResumeState, - isSuccess -} from './mobile-tasks-legacy-foundation' +import type { GitHubPreset, RepoSummary, TaskResumeState } from './mobile-tasks-legacy-foundation' +import { taskSettingsWrite, taskUiStateWrite } from './mobile-task-runtime-operations' export function useMobileTasksClientSettingsActions(model: ProjectRepositoryResolutionModel) { const { @@ -106,7 +102,7 @@ export function useMobileTasksClientSettingsActions(model: ProjectRepositoryReso } const next = { ...taskResumeRef.current, ...updates } taskResumeRef.current = next - void client.sendRequest('ui.set', { taskResumeState: next }).catch(() => { + void taskUiStateWrite.request(client, { taskResumeState: next }).catch(() => { // Best-effort: desktop treats task resume as a convenience preference. }) }, @@ -143,7 +139,7 @@ export function useMobileTasksClientSettingsActions(model: ProjectRepositoryReso if (!client || !taskUiReady) { return } - void client.sendRequest('settings.update', { defaultTaskSource: nextProvider }).catch(() => { + void taskSettingsWrite.request(client, { defaultTaskSource: nextProvider }).catch(() => { // Best-effort: a failed settings write should not block switching views. }) }, @@ -158,11 +154,9 @@ export function useMobileTasksClientSettingsActions(model: ProjectRepositoryReso const nextSelection = selection.size === 0 || selection.size === allRepos.length ? null : [...selection] defaultRepoSelectionRef.current = nextSelection - void client - .sendRequest('settings.update', { defaultRepoSelection: nextSelection }) - .catch(() => { - // Best-effort: the in-memory repo picker already reflects the change. - }) + void taskSettingsWrite.request(client, { defaultRepoSelection: nextSelection }).catch(() => { + // Best-effort: the in-memory repo picker already reflects the change. + }) }, [client, taskUiReady] ) @@ -173,7 +167,7 @@ export function useMobileTasksClientSettingsActions(model: ProjectRepositoryReso if (!client || !taskUiReady) { return } - void client.sendRequest('settings.update', { defaultTaskViewPreset: preset }).catch(() => { + void taskSettingsWrite.request(client, { defaultTaskViewPreset: preset }).catch(() => { // Best-effort: the current session still uses the selected preset. }) }, @@ -186,7 +180,7 @@ export function useMobileTasksClientSettingsActions(model: ProjectRepositoryReso if (!client || !taskUiReady) { return } - void client.sendRequest('settings.update', { githubProjects: nextSettings }).catch(() => { + void taskSettingsWrite.request(client, { githubProjects: nextSettings }).catch(() => { // Best-effort: project selection can still work for the current session. }) }, @@ -204,10 +198,7 @@ export function useMobileTasksClientSettingsActions(model: ProjectRepositoryReso contentHash, alwaysTrust }) - const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } + taskUiStateWrite.interpret(await taskUiStateWrite.request(client, { trustedOrcaHooks: next })) setTrustedOrcaHooks(next) }, [client, trustedOrcaHooks] diff --git a/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx b/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx index 1ededc361bc..2372ea272c9 100644 --- a/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx +++ b/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx @@ -1,5 +1,11 @@ import { settingsRead } from '../transport/settings-read-operations' import type { ClientSettingsActionsModel } from './use-mobile-tasks-client-settings-actions' +import { + taskLinearStatusRead, + taskPreflightRead, + taskRuntimeStatusRead, + taskUiStateRead +} from './mobile-task-runtime-operations' import { MOBILE_TASKS_CAPABILITY, type PersistedTrustedOrcaHooks, @@ -18,7 +24,6 @@ import { type TaskRuntimeStatus, getTaskPresetQuery, githubKindFromQuery, - isSuccess, isTaskProvider, normalizeGitHubPreset, normalizeLinearFilter, @@ -192,14 +197,14 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel resetWorkspaceCreateState() const hydrateTaskState = async (): Promise => { - const statusResponse = await client.sendRequest('status.get') + const statusReply = await taskRuntimeStatusRead.request(client) if (stale) { return } - if (!isSuccess(statusResponse)) { - throw new Error(statusResponse.error.message) - } - const status = statusResponse.result as TaskRuntimeStatus + // The guard stays between the request and the interpretation: a screen that has moved on + // must not raise a refusal it no longer owns. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const status = taskRuntimeStatusRead.interpret(statusReply) as TaskRuntimeStatus if (!status.capabilities?.includes(MOBILE_TASKS_CAPABILITY)) { // Why: Tasks is additive RPC surface, so old desktop builds can still // pair but must not receive the newer task-specific method calls. @@ -249,13 +254,15 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel } setTasksSupportState({ kind: 'supported', client }) setError('') - const [settingsResponse, uiResponse, preflightResponse, linearStatusResponse] = - await Promise.all([ - settingsRead.request(client), - client.sendRequest('ui.get'), - client.sendRequest('preflight.check'), - client.sendRequest('linear.status') - ]) + // Why raw requests in the group and not startRpcOperation: main's Promise.all rejects as soon + // as one leg rejects, and interpreting at an all-settled barrier would instead wait for the + // slowest peer and let a later policy surface a different error. + const [settingsResponse, uiReply, preflightReply, linearStatusReply] = await Promise.all([ + settingsRead.request(client), + taskUiStateRead.request(client), + taskPreflightRead.request(client), + taskLinearStatusRead.request(client) + ]) if (stale) { return } @@ -266,26 +273,30 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel ((settingsResult.value ?? {}) as RuntimeTaskSettings) : {} setRuntimeTaskSettings(settings) - const uiState = isSuccess(uiResponse) - ? ( - uiResponse.result as { - ui?: { + const uiRead = taskUiStateRead.interpret(uiReply) + const uiState = uiRead.accepted + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + (uiRead.value as + | { taskResumeState?: TaskResumeState trustedOrcaHooks?: PersistedTrustedOrcaHooks } - } - ).ui + | undefined) : null setTrustedOrcaHooks(uiState?.trustedOrcaHooks ?? {}) const resume = uiState?.taskResumeState ?? {} taskResumeRef.current = resume setGithubProjectHiddenFieldIdsByView(resume.githubProjectHiddenFieldIdsByView ?? {}) - const preflight = isSuccess(preflightResponse) - ? (preflightResponse.result as { glab?: { installed?: boolean } }) + const preflightRead = taskPreflightRead.interpret(preflightReply) + const preflight = preflightRead.accepted + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + (preflightRead.value as { glab?: { installed?: boolean } }) : null - const linearStatus = isSuccess(linearStatusResponse) - ? (linearStatusResponse.result as LinearStatusResponse) + const linearRead = taskLinearStatusRead.interpret(linearStatusReply) + const linearStatus = linearRead.accepted + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + (linearRead.value as LinearStatusResponse) : null const preferredProviders = normalizeVisibleTaskProviders(settings.visibleTaskProviders) const linearIsConnected = linearStatus?.connected === true diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx index 5befaddaebd..0cfaa2e3d42 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx @@ -11,13 +11,18 @@ import { useCallback, wasSetupHookPreviouslyApproved } from './mobile-tasks-dependencies' -import { - type ActionableTaskItem, - type GitPushTarget, - type RuntimeTaskSettings, - type SetupDecision, - isSuccess +import type { + ActionableTaskItem, + GitPushTarget, + RuntimeTaskSettings, + SetupDecision } from './mobile-tasks-legacy-foundation' +import type { WorkspaceCreateParams } from './workspace-create-params' +import { + worktreeCreateRun, + worktreeMrBaseResolve, + worktreePrBaseResolve +} from './mobile-workspace-create-operations' export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateModel) { const { @@ -154,7 +159,7 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod const trimmedWorkspaceName = workspaceNameOverride?.trim() ?? '' const nameIsAutoManaged = !trimmedWorkspaceName || trimmedWorkspaceName === workspaceLastAutoName - let params: Record + let params: WorkspaceCreateParams if (item.provider === 'github') { const source = item.source let prStartPoint: { baseBranch: string; pushTarget?: GitPushTarget } | undefined @@ -164,8 +169,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod baseBranchOverride }) ) { - const response = await client.sendRequest( - 'worktree.resolvePrBase', + const reply = await worktreePrBaseResolve.request( + client, { repo: `id:${source.repoId}`, prNumber: source.number, @@ -176,10 +181,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = worktreePrBaseResolve.interpret(reply) as | { baseBranch: string; pushTarget?: GitPushTarget } | { error: string } if ('error' in result) { @@ -209,8 +212,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod baseBranchOverride }) ) { - const response = await client.sendRequest( - 'worktree.resolveMrBase', + const reply = await worktreeMrBaseResolve.request( + client, { repo: `id:${source.repoId}`, mrIid: source.number, @@ -221,10 +224,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod }, { timeoutMs: 30_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = worktreeMrBaseResolve.interpret(reply) as | { baseBranch: string; pushTarget?: GitPushTarget } | { error: string } if ('error' in result) { @@ -259,13 +260,11 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod nameIsAutoManaged }) } - const response = await client.sendRequest('worktree.create', params, { + const createReply = await worktreeCreateRun.request(client, params, { timeoutMs: WORKTREE_CREATE_TIMEOUT_MS }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = worktreeCreateRun.interpret(createReply) as { worktree: { id: string; displayName?: string } warning?: string } diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx index 93d0d45de09..ea780216170 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx @@ -1,6 +1,9 @@ import type { WorkspaceCreateProjectionModel } from './use-mobile-tasks-workspace-create-projection' import { type BaseRefSearchResult, type SparsePreset, useEffect } from './mobile-tasks-dependencies' -import { isSuccess } from './mobile-tasks-legacy-foundation' +import { + repoBaseRefSearchRead, + repoSparsePresetListRead +} from './mobile-workspace-source-operations' export function useMobileTasksWorkspaceSourceEffects(model: WorkspaceCreateProjectionModel) { const { @@ -45,16 +48,15 @@ export function useMobileTasksWorkspaceSourceEffects(model: WorkspaceCreateProje setWorkspaceSparsePresetsLoading(true) setWorkspaceSparsePresetsLoaded(false) setWorkspaceSparsePresetsError('') - void client - .sendRequest('repo.sparsePresets', { repo: `id:${workspaceCreateTargetRepo.id}` }) - .then((response) => { + void repoSparsePresetListRead + .request(client, { repo: `id:${workspaceCreateTargetRepo.id}` }) + .then((reply) => { if (stale) { return } - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const presets = (response.result as { presets?: SparsePreset[] }).presets ?? [] + const presets = + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + (repoSparsePresetListRead.interpret(reply) as SparsePreset[] | undefined) ?? [] setWorkspaceSparsePresets(presets) setWorkspaceSparsePresetsLoaded(true) setWorkspaceSparsePresetId((current) => @@ -112,20 +114,18 @@ export function useMobileTasksWorkspaceSourceEffects(model: WorkspaceCreateProje let stale = false setWorkspaceBaseBranchLoading(true) setWorkspaceBaseBranchError('') - void client - .sendRequest( - 'repo.searchRefs', + void repoBaseRefSearchRead + .request( + client, { repo: `id:${workspaceCreateTargetRepo.id}`, query, limit: 20 }, { timeoutMs: 30_000 } ) - .then((response) => { + .then((reply) => { if (stale) { return } - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = repoBaseRefSearchRead.interpret(reply) as { refDetails?: BaseRefSearchResult[] refs?: string[] } diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx index d2b9551032f..ef2ecabd4fe 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx @@ -5,7 +5,8 @@ import { useCallback, useEffect } from './mobile-tasks-dependencies' -import { isSuccess, sortSparsePresetsByName } from './mobile-tasks-legacy-foundation' +import { sortSparsePresetsByName } from './mobile-tasks-legacy-foundation' +import { repoSparsePresetSaveRun, sshRepoStateRead } from './mobile-workspace-source-operations' export function useMobileTasksWorkspaceSparseActions(model: WorkspaceSourceEffectsModel) { const { @@ -82,16 +83,14 @@ export function useMobileTasksWorkspaceSparseActions(model: WorkspaceSourceEffec setWorkspaceSparseSaving(true) setWorkspaceSparsePresetsError('') try { - const response = await client.sendRequest('repo.saveSparsePreset', { + const reply = await repoSparsePresetSaveRun.request(client, { repo: `id:${workspaceCreateTargetRepo.id}`, ...(workspaceSparseDraft.presetId ? { id: workspaceSparseDraft.presetId } : {}), name: workspaceSparseDraftName, directories: workspaceSparseDraftParsed.directories }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const saved = (response.result as { preset?: SparsePreset }).preset + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const saved = repoSparsePresetSaveRun.interpret(reply) as SparsePreset | undefined if (!saved) { throw new Error('Failed to save sparse preset.') } @@ -130,16 +129,15 @@ export function useMobileTasksWorkspaceSparseActions(model: WorkspaceSourceEffec } let stale = false - void client - .sendRequest('ssh.getState', { targetId: workspaceCreateTargetConnectionId }) - .then((response) => { + void sshRepoStateRead + .request(client, { targetId: workspaceCreateTargetConnectionId }) + .then((reply) => { if (stale) { return } - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const state = (response.result as { state?: SshConnectionState | null }).state ?? null + const state = + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + (sshRepoStateRead.interpret(reply) as SshConnectionState | null | undefined) ?? null setWorkspaceSshState( state ?? { targetId: workspaceCreateTargetConnectionId, diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx index d1a408aed22..5e0966ca9ba 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx @@ -8,12 +8,18 @@ import { useEffect, useMemo } from './mobile-tasks-dependencies' -import { - type RepoHooksResponse, - type RepoSummary, - type SetupDecision, - isSuccess +import type { + RepoHooksResponse, + RepoSummary, + SetupDecision } from './mobile-tasks-legacy-foundation' +import { + localAgentDetectionRead, + remoteAgentDetectionRead, + repoSetupHooksRead, + sshRepoConnectRun, + sshRepoStateRead +} from './mobile-workspace-source-operations' export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsModel) { const { @@ -47,15 +53,13 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod reconnectAttempt: 0 }) try { - const response = await client.sendRequest( - 'ssh.connect', + const reply = await sshRepoConnectRun.request( + client, { targetId: workspaceCreateTargetConnectionId }, { timeoutMs: 120_000 } ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const state = (response.result as { state?: SshConnectionState | null }).state + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const state = sshRepoConnectRun.interpret(reply) as SshConnectionState | null | undefined setWorkspaceSshState( state ?? { targetId: workspaceCreateTargetConnectionId, @@ -87,11 +91,10 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod ) { return } - const response = await client.sendRequest('ssh.getState', { targetId: repo.connectionId }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const state = (response.result as { state?: SshConnectionState | null }).state ?? null + const reply = await sshRepoStateRead.request(client, { targetId: repo.connectionId }) + const state = + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + (sshRepoStateRead.interpret(reply) as SshConnectionState | null | undefined) ?? null if (state) { setWorkspaceSshState(state) } @@ -115,18 +118,23 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod } let stale = false setWorkspaceDetectedAgentIds(null) - const request = workspaceCreateTargetRepo.connectionId - ? client.sendRequest('preflight.detectRemoteAgents', { - connectionId: workspaceCreateTargetRepo.connectionId - }) - : client.sendRequest('preflight.detectAgents') - void request - .then((response) => { + const detection = workspaceCreateTargetRepo.connectionId + ? { + operation: remoteAgentDetectionRead, + reply: remoteAgentDetectionRead.request(client, { + connectionId: workspaceCreateTargetRepo.connectionId + }) + } + : { operation: localAgentDetectionRead, reply: localAgentDetectionRead.request(client) } + void detection.reply + .then((reply) => { if (stale) { return } + const detected = detection.operation.interpret(reply) setWorkspaceDetectedAgentIds( - isSuccess(response) ? new Set(response.result as string[]) : new Set() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + detected.accepted ? new Set(detected.value as string[]) : new Set() ) }) .catch(() => { @@ -190,11 +198,9 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod if (!client || !tasksSupported) { return { kind: 'decision', decision: override ?? 'inherit' } } - const response = await client.sendRequest('repo.hooks', { repo: `id:${repo.id}` }) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - const result = response.result as RepoHooksResponse + const reply = await repoSetupHooksRead.request(client, { repo: `id:${repo.id}` }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = repoSetupHooksRead.interpret(reply) as RepoHooksResponse const setupCommand = result.hooks?.scripts?.setup?.trim() const setupTrust = normalizeSetupHookTrust(result.setupTrust) ?? undefined if (!setupCommand) { diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index 218c4fe37b0..5f5adbecf5f 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -4,6 +4,7 @@ import type { SetupDecision } from '../../../src/shared/worktree/create-types' import type { GitPushTarget } from '../../../src/shared/worktree/types' +import type { RpcSendParams } from '../transport/rpc-params-contract' import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source' import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' import type { WorkspaceAgentChoice } from './workspace-agent-selection' @@ -55,7 +56,8 @@ export type WorkspaceCreateTaskItem = | WorkspaceCreateGitLabItem | WorkspaceCreateLinearItem -export type WorkspaceCreateParams = Record +/** The outgoing worktree.create params, so the builder and the operation agree by type. */ +export type WorkspaceCreateParams = RpcSendParams<'worktree.create'> /** * `worktree.create` fields for launching the picked agent in a fresh session. diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index c4ca8170312..63a67dda2e8 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { RpcClient } from '../transport/rpc-client' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' -import type { RpcSuccess } from '../transport/types' import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' +import { worktreeCreateCapabilityRead } from './mobile-workspace-create-operations' import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability' import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS, @@ -36,11 +36,14 @@ export async function readNewWorktreeRuntimeCapabilities( ): Promise { for (let migrationRetry = 0; ; migrationRetry += 1) { try { - const response = await client.sendRequest('status.get') - if (!response.ok) { + const status = worktreeCreateCapabilityRead.interpret( + await worktreeCreateCapabilityRead.request(client) + ) + if (!status.accepted) { return UNSUPPORTED_CAPABILITIES } - const result = (response as RpcSuccess).result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = status.value as { capabilities?: string[] worktreeCreateIdempotency?: unknown } diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts index 1611ed74c19..b61ab20c30e 100644 --- a/mobile/src/tasks/worktree-create-retry.test.ts +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { ConnectionState } from '../transport/types' import { @@ -789,6 +789,49 @@ describe('createWorktreeWithNameRetry', () => { expect(attempts).toHaveLength(1) }) + // The delivery-unknown mark is a WeakSet keyed on the rejection object, so the create must reach + // the caller as the very object the transport rejected with. `worktreeCreateRun.request` returns + // the transport promise itself for exactly this reason; an operation that wrapped, re-threw or + // re-created the error would turn "the host may have built it" into "it failed". + it('rethrows the transport rejection object itself, mark and all', async () => { + const attempts: Attempt[] = [] + const connection = connectionController() + const marked = markRpcDeliveryUnknown(new Error('Connection lost')) + const client = scriptedClient([{ throws: marked }], attempts, connection) + // Idempotency off, so the resilient sender rethrows on the first ambiguity instead of replaying + // and the object under test is the one the transport produced, not a later attempt's. + const caught = await createWorktreeWithNameRetry({ + client, + baseName: 'kestrel', + buildParams: (name) => ({ repo: 'id:r', name }), + worktreeCreateIdempotency: false + }).then( + () => null, + (error: unknown) => error + ) + expect(caught).toBe(marked) + expect(isRpcDeliveryUnknown(caught)).toBe(true) + }) + + // The other direction: a definite failure must not acquire a mark on the way out, or a create the + // host never received would be replayed as a reconciliation and build a second worktree. + it('does not mark a rejection the transport left unmarked', async () => { + const attempts: Attempt[] = [] + const unmarked = new Error('Socket closed before send') + const client = scriptedClient([{ throws: unmarked }], attempts, connectionController()) + const caught = await createWorktreeWithNameRetry({ + client, + baseName: 'kestrel', + buildParams: (name) => ({ repo: 'id:r', name }), + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT + }).then( + () => null, + (error: unknown) => error + ) + expect(caught).toBe(unmarked) + expect(isRpcDeliveryUnknown(caught)).toBe(false) + }) + it('keeps the replay window strictly inside the host dedupe TTL', () => { // The window is measured from a lower bound on when the host could have resolved, so // it has to leave the record room for the replay to still be in flight. Widening it diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index b0f8f618773..fae847367d0 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -1,6 +1,7 @@ import type { RpcClient } from '../transport/rpc-client' -import type { RpcResponse, RpcSuccess } from '../transport/types' +import type { RpcResponse } from '../transport/types' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { worktreeCreateRun } from './mobile-workspace-create-operations' import { waitForRpcClientReconnected } from '../transport/rpc-client-reconnect-wait' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { @@ -10,6 +11,7 @@ import { isRetryableWorktreeCreateConflict } from '../../../src/shared/new-workspace/worktree-create-retry-policy' import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' +import type { WorkspaceCreateParams } from './workspace-create-params' import { getWorktreeCreateReplayWindowMs, type WorktreeCreateIdempotencyProbe, @@ -49,7 +51,7 @@ export type CreateWorktreeWithNameRetryArgs = { client: RpcClient baseName: string nameWasGenerated?: boolean - buildParams: (name: string) => Record + buildParams: (name: string) => WorkspaceCreateParams worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe maxAttempts?: number // Injected in tests; production mints a fresh idempotency key per candidate. @@ -83,8 +85,11 @@ export async function createWorktreeWithNameRetry( ? { ...candidateParams, clientMutationId: mintMutationId() } : candidateParams const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency) + // Why the raw refusal: the retry decision below is `isRetryableWorktreeCreateConflict` over the + // host's message, and no acceptance policy carries a refusal message through without throwing. if (response.ok) { - const result = (response as RpcSuccess).result as { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + const result = worktreeCreateRun.interpret(response) as { worktree: { id: string; displayName?: string } warning?: string } @@ -116,7 +121,7 @@ export async function createWorktreeWithNameRetry( // is returned to the caller untouched. async function sendWorktreeCreateResilient( client: RpcClient, - params: Record, + params: WorkspaceCreateParams, worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false ): Promise { let migrationRetry = 0 @@ -125,7 +130,9 @@ async function sendWorktreeCreateResilient( let replayDeadlineAt: number | null = null for (;;) { try { - return await client.sendRequest('worktree.create', params, { + // `request` is the transport promise itself, so a delivery-unknown rejection reaches the + // catch below as the object the transport marked — the WeakSet cannot see through a wrapper. + return await worktreeCreateRun.request(client, params, { timeoutMs: WORKTREE_CREATE_TIMEOUT_MS }) } catch (error) { diff --git a/mobile/src/test-support/rpc-recording/operation-module-loader.ts b/mobile/src/test-support/rpc-recording/operation-module-loader.ts index 03c31a4f707..aade578406a 100644 --- a/mobile/src/test-support/rpc-recording/operation-module-loader.ts +++ b/mobile/src/test-support/rpc-recording/operation-module-loader.ts @@ -4,13 +4,20 @@ import { dirname, resolve } from 'node:path' import * as React from 'react' import ts from 'typescript' import { OPERATION_EXPOSURES, OPERATION_MUTATIONS, type Mutation } from './operation-mutations' +import * as deliveryAmbiguity from '../../transport/rpc-delivery-ambiguity' export type { Mutation } export type OperationModule = Record unknown> +// Why shared rather than evaluated: the delivery-unknown mark is a WeakSet keyed on the rejection +// object, so a second copy of the module has a second, empty registry and every marked rejection +// reads as a definite failure inside the mounted operation. Same reason React is shared. +const SHARED_MODULE = 'mobile/src/transport/rpc-delivery-ambiguity.ts' + // Only mounting boundaries are substituted; every operation and projection is loaded from source. export function operationModuleLoader(root: string, mutation?: Mutation) { const cache = new Map() + const sharedModulePath = resolve(root, SHARED_MODULE) let mutationCount = 0 function pathFor(base: string): string { const file = ['', '.ts', '.tsx', '/index.ts'] @@ -25,6 +32,9 @@ export function operationModuleLoader(root: string, mutation?: Mutation) { if (name === 'react') { return React } + if (name.startsWith('.') && pathFor(resolve(dirname(base), name)) === sharedModulePath) { + return deliveryAmbiguity + } if (!name.startsWith('.')) { return new Proxy( {}, diff --git a/mobile/src/test-support/rpc-recording/operation-mutations.ts b/mobile/src/test-support/rpc-recording/operation-mutations.ts index cd760f0004f..e543a14e398 100644 --- a/mobile/src/test-support/rpc-recording/operation-mutations.ts +++ b/mobile/src/test-support/rpc-recording/operation-mutations.ts @@ -70,22 +70,19 @@ export const OPERATION_MUTATIONS = { before: '((settingsResult.value ?? {}) as RuntimeTaskSettings)', after: '((settingsResponse.result ?? {}) as RuntimeTaskSettings)' }, - // Applies the preset only after the write settles, dropping the optimistic update. + // Moves the optimistic preset write behind the guard that only an unusable client takes, so the + // preset the screen shows never follows the tap. Anchored above the send so the step-4 migration + // of this file does not move it; the projection it proves load-bearing is the same one. 'task-preferences-optimistic': { file: 'use-mobile-tasks-client-settings-actions.tsx', before: ` setDefaultGitHubPreset(preset) if (!client || !taskUiReady) { return - } - void client.sendRequest('settings.update', { defaultTaskViewPreset: preset }).catch(() => {`, + }`, after: ` if (!client || !taskUiReady) { setDefaultGitHubPreset(preset) return - } - void client - .sendRequest('settings.update', { defaultTaskViewPreset: preset }) - .then(() => setDefaultGitHubPreset(preset)) - .catch(() => {` + }` }, // Publishes the settings envelope as the refreshed workspace runtime settings. 'workspace-submit-envelope': { diff --git a/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts b/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts index a1dd2b61174..dad00135eb7 100644 --- a/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/pilot-mount-adapters.ts @@ -2,6 +2,8 @@ import { observableModel } from './observable-model' import { hostedReviewMountAdapters } from './hosted-review-mount-adapters' import { settingsMountAdapters } from './settings-mount-adapters' import { sourceControlMountAdapters } from './source-control-mount-adapters' +import { taskWorkspaceHookMountAdapters } from './task-workspace-hook-mount-adapters' +import { taskWorkspaceSenderMountAdapters } from './task-workspace-sender-mount-adapters' import { workspaceSettingsMounts } from './workspace-settings-mounts' import type { MountAdapter } from './recording-scenario' import { hookMount, performHookAction } from './hook-mount' @@ -16,6 +18,8 @@ export function pilotMountAdapters( ...settingsMountAdapters(modules), ...workspaceSettingsMounts(modules), ...sourceControlMountAdapters(modules), + ...taskWorkspaceSenderMountAdapters(modules), + ...taskWorkspaceHookMountAdapters(modules), ...hostedReviewMountAdapters(modules), 'workspace.file-inventory': ({ client }) => { const useSearch = modules.load< @@ -220,6 +224,12 @@ export function pilotMountAdapters( args.preset as Parameters[0] ) } + if (name === 'resume') { + return actions.persistTaskResumeState({ githubItemsPreset: 'issues' }) + } + if (name === 'trust') { + return actions.persistSetupHookTrust('repo-1', 'hash-1', false) + } throw new Error(`Unknown preferences action: ${name}`) }, state: () => ({ preset: model.defaultGitHubPreset }), diff --git a/mobile/src/test-support/rpc-recording/task-workspace-hook-mount-adapters.ts b/mobile/src/test-support/rpc-recording/task-workspace-hook-mount-adapters.ts new file mode 100644 index 00000000000..6b51300f843 --- /dev/null +++ b/mobile/src/test-support/rpc-recording/task-workspace-hook-mount-adapters.ts @@ -0,0 +1,193 @@ +import type { MountAdapter } from './recording-scenario' +import { hookMount, performHookAction } from './hook-mount' +import { observableModel, projectObservable } from './observable-model' +import type { operationModuleLoader } from './operation-module-loader' + +const REPO = 'repo-1' + +/** + * The workspace-create drawer's three model-chained hooks, mounted the way the settings adapters + * mount theirs: a fixture model supplying only the members the hook destructures, with every setter + * recorded as an effect. + */ +export function taskWorkspaceHookMountAdapters( + modules: ReturnType +): Record { + // The drawer's SSH hook. `connectionId` picks the arm the detection effect takes: a repo on + // an SSH connection detects remote agents, one without it detects local agents. + function sshStateAdapter(connectionId: string | undefined): MountAdapter { + return (context) => { + const useSsh = modules.load< + typeof import('../../tasks/use-mobile-tasks-workspace-ssh-state') + >('mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx').useMobileTasksWorkspaceSshState + const repo = { id: REPO, displayName: 'Repo', connectionId } + const model = observableModel(context, { + client: context.client, + tasksSupported: true, + runtimeTaskSettings: { disabledTuiAgents: [] }, + workspaceAgent: null, + workspaceAgentOverridden: false, + workspaceCreateDraft: { key: 'linear:1' }, + workspaceCreateRequiresSshConnection: false, + workspaceCreateSshStatus: connectionId ? 'connected' : 'idle', + workspaceCreateTargetConnectionId: connectionId, + workspaceCreateTargetRepo: repo, + workspaceDetectedAgentIds: null, + workspaceSshState: null, + workspaceSshConnecting: false + }) + let actions: ReturnType + const hook = hookMount(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. + actions = useSsh(model as unknown as Parameters[0]) + }) + let setup: unknown = 'unresolved' + return { + action(name) { + if (name === 'mount') { + return hook.mount() + } + if (name === 'connect') { + return performHookAction(() => actions.connectWorkspaceSshRepo()) + } + if (name === 'ensure-ready') { + return actions.ensureWorkspaceSshReady( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook reads only id, displayName and connectionId. + repo as Parameters[0] + ) + } + if (name === 'resolve-setup') { + return actions + .resolveCreateSetupDecision( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above. + repo as Parameters[0] + ) + .then((value: unknown) => { + setup = value + return value + }) + } + throw new Error(`Unknown workspace ssh action: ${name}`) + }, + state: () => + projectObservable({ + ssh: model.workspaceSshState, + connecting: model.workspaceSshConnecting, + detected: model.workspaceDetectedAgentIds, + agent: model.workspaceAgent, + setup + }), + dispose: hook.unmount + } + } + } + + return { + 'tasks.workspace-source': (context) => { + const useEffects = modules.load< + typeof import('../../tasks/use-mobile-tasks-workspace-source-effects') + >( + 'mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx' + ).useMobileTasksWorkspaceSourceEffects + const model = observableModel(context, { + client: context.client, + tasksSupported: true, + workspaceCreateDraft: { key: 'linear:1' }, + workspaceCreateTargetRepo: { id: REPO, displayName: 'Repo' }, + workspaceSparseReloadKey: 0, + workspaceBaseBranchQuery: '', + showWorkspaceBaseBranchPicker: false, + workspaceSparsePresets: [], + workspaceSparsePresetsLoaded: false, + workspaceSparsePresetsLoading: false, + workspaceSparsePresetsError: '', + workspaceSparsePresetId: null, + workspaceSparseDraft: null, + workspaceBaseBranchResults: [], + workspaceBaseBranchLoading: false, + workspaceBaseBranchError: '' + }) + const hook = hookMount(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. + useEffects(model as unknown as Parameters[0]) + }) + return { + action(name, args) { + if (name === 'mount') { + return hook.mount() + } + if (name === 'branch-query') { + model.showWorkspaceBaseBranchPicker = true + model.workspaceBaseBranchQuery = String(args.query ?? 'main') + return hook.update() + } + throw new Error(`Unknown workspace source action: ${name}`) + }, + state: () => + projectObservable({ + presets: model.workspaceSparsePresets, + presetsLoaded: model.workspaceSparsePresetsLoaded, + presetsError: model.workspaceSparsePresetsError, + branches: model.workspaceBaseBranchResults, + branchError: model.workspaceBaseBranchError + }), + dispose: hook.unmount + } + }, + 'tasks.workspace-sparse': (context) => { + const useSparse = modules.load< + typeof import('../../tasks/use-mobile-tasks-workspace-sparse-actions') + >( + 'mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx' + ).useMobileTasksWorkspaceSparseActions + const model = observableModel(context, { + client: context.client, + tasksSupported: true, + canSaveWorkspaceSparseDraft: true, + workspaceCreateDraft: { key: 'linear:1' }, + workspaceCreateTargetConnectionId: 'ssh-1', + workspaceCreateTargetRepo: { id: REPO, displayName: 'Repo' }, + workspaceSparseCheckoutAvailable: true, + workspaceSparseDraft: { mode: 'new', name: 'docs', directoriesText: 'docs' }, + workspaceSparseDraftName: 'docs', + workspaceSparseDraftParsed: { directories: ['docs'] }, + workspaceSparsePresetId: null, + workspaceSparsePresets: [], + workspaceSparsePresetsLoaded: false, + workspaceSparsePresetsLoading: false, + workspaceSparsePresetsError: '', + workspaceSparseSaving: false, + workspaceSshState: null, + workspaceSshConnecting: false, + showWorkspaceSparsePicker: false + }) + let actions: ReturnType + const hook = hookMount(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. + actions = useSparse(model as unknown as Parameters[0]) + }) + return { + action(name) { + if (name === 'mount') { + return hook.mount() + } + if (name === 'save-preset') { + return performHookAction(() => actions.saveWorkspaceSparsePreset()) + } + throw new Error(`Unknown workspace sparse action: ${name}`) + }, + state: () => + projectObservable({ + presets: model.workspaceSparsePresets, + presetsError: model.workspaceSparsePresetsError, + saving: model.workspaceSparseSaving, + ssh: model.workspaceSshState + }), + dispose: hook.unmount + } + }, + 'tasks.workspace-ssh': sshStateAdapter('ssh-1'), + // The local arm: no connectionId, so the effect calls preflight.detectAgents. + 'tasks.workspace-ssh-local': sshStateAdapter(undefined) + } +} diff --git a/mobile/src/test-support/rpc-recording/task-workspace-sender-mount-adapters.ts b/mobile/src/test-support/rpc-recording/task-workspace-sender-mount-adapters.ts new file mode 100644 index 00000000000..1cfa3911358 --- /dev/null +++ b/mobile/src/test-support/rpc-recording/task-workspace-sender-mount-adapters.ts @@ -0,0 +1,180 @@ +import type { MountAdapter } from './recording-scenario' +import type { operationModuleLoader } from './operation-module-loader' + +const REPO = 'repo-1' +const REPO_SELECTOR = `id:${REPO}` + +/** + * The task workspace-creation senders that are exported async functions taking a client: create and + * its retry loop, the create-time capability probe, hosted-base resolution, the setup-hook trust + * write and the Smart source picker's provider reads. No React host is needed, so the recorded + * state is the function's own answer. + */ +export function taskWorkspaceSenderMountAdapters( + modules: ReturnType +): Record { + return { + 'tasks.worktree-create-retry': ({ client }) => { + const create = modules.load( + 'mobile/src/tasks/worktree-create-retry.ts' + ).createWorktreeWithNameRetry + let outcome: unknown = 'uncreated' + let minted = 0 + return { + action: (_name, args) => + create({ + client, + baseName: String(args.name ?? 'kestrel'), + buildParams: (candidate: string) => ({ repo: REPO_SELECTOR, name: candidate }), + // A resolved probe, because the create path awaits it before the first send. + worktreeCreateIdempotency: Promise.resolve( + args.idempotency === false ? false : { dedupeTtlMs: 60_000 } + ), + ...(args.maxAttempts === undefined ? {} : { maxAttempts: Number(args.maxAttempts) }), + mintMutationId: () => `mutation-${++minted}` + }).then((value: unknown) => { + outcome = value + return value + }), + state: () => ({ outcome }), + dispose: () => {} + } + }, + 'tasks.worktree-capabilities': ({ client }) => { + const read = modules.load( + 'mobile/src/tasks/worktree-create-capability.ts' + ).readNewWorktreeRuntimeCapabilities + let capabilities: unknown = 'unprobed' + return { + action: () => + read(client).then((value: unknown) => { + capabilities = value + return value + }), + state: () => ({ capabilities }), + dispose: () => {} + } + }, + 'tasks.composer-hosted-base': ({ client }) => { + const resolve = modules.load( + 'mobile/src/tasks/composer-source-base-resolve.ts' + ) + let prBase: unknown = 'unresolved' + let mrBase: unknown = 'unresolved' + return { + action(name) { + if (name === 'mr-base') { + return resolve + .resolveComposerMrBase({ client, repoId: REPO, mrIid: 7, sourceBranch: 'feature' }) + .then((value: unknown) => { + mrBase = value + return value + }) + } + return resolve + .resolveComposerPrBase({ client, repoId: REPO, prNumber: 12, headRefName: 'feature' }) + .then((value: unknown) => { + prBase = value + return value + }) + }, + state: () => ({ prBase, mrBase }), + dispose: () => {} + } + }, + 'tasks.setup-hook-trust': ({ client }) => { + const persist = modules.load( + 'mobile/src/tasks/setup-hook-trust.ts' + ).persistSetupHookTrustApproval + let trust: unknown = 'unapproved' + return { + action: (_name, args) => + persist({ + client, + trust: {}, + repoId: REPO, + contentHash: 'hash-1', + alwaysTrust: args.always === true + }).then((value: unknown) => { + trust = value + return value + }), + state: () => ({ trust }), + dispose: () => {} + } + }, + 'tasks.smart-source-search': ({ client }) => { + const search = modules.load( + 'mobile/src/tasks/smart-source-search-requests.ts' + ) + const results: Record = {} + return { + action(name, args) { + const query = String(args.query ?? 'bug') + const request = + name === 'gitlab' + ? search.searchGitLabItems(client, REPO, query, 'opened') + : name === 'linear' + ? search.searchLinearIssues( + client, + query, + args.workspace === null ? null : String(args.workspace ?? 'linear-workspace') + ) + : name === 'branches' + ? search.searchBranches(client, REPO, query) + : search.searchGitHubItems(client, REPO, query) + return request.then((value: unknown) => { + results[name] = value + return value + }) + }, + state: () => ({ ...results }), + dispose: () => {} + } + }, + 'tasks.paste-lookup': ({ client }) => { + const paste = modules.load( + 'mobile/src/tasks/smart-source-paste-intent.ts' + ) + const slugCache = new Map() + const repos = [ + { id: REPO, displayName: 'Repo', slug: null }, + { id: 'repo-2', displayName: 'Other', slug: null } + ] + const results: Record = {} + return { + action(name) { + const request = + name === 'by-number' + ? paste.lookupGitHubItemByNumber(client, REPO, 12) + : name === 'by-slug' + ? paste.lookupGitHubItemByOwnerRepo( + client, + REPO, + { owner: 'owner', repo: 'repo' }, + 12, + 'issue' + ) + : name === 'gitlab-path' + ? paste.lookupGitLabItemByPath(client, REPO, { + slug: { host: 'gitlab.com', path: 'group/project' }, + number: 7, + type: 'issue' + }) + : paste.findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'owner', repo: 'repo' }, + slugCache + ) + return request.then((value: unknown) => { + results[name] = value + return value + }) + }, + state: () => ({ ...results, cache: [...slugCache] }), + dispose: () => {} + } + } + } +} diff --git a/mobile/src/test-support/rpc-recording/workspace-settings-mounts.ts b/mobile/src/test-support/rpc-recording/workspace-settings-mounts.ts index d37c3d40935..730a75736cf 100644 --- a/mobile/src/test-support/rpc-recording/workspace-settings-mounts.ts +++ b/mobile/src/test-support/rpc-recording/workspace-settings-mounts.ts @@ -6,6 +6,75 @@ import { operationModuleLoader } from './operation-module-loader' export function workspaceSettingsMounts( modules: ReturnType ): Record { + // `settings.task-workspace` stops at the setup prompt, which is the branch that scenario set + // exercises. A second registration resolves setup instead, so createWorkspace runs to + // worktree.create and the reply matrix reaches that call's acceptance policy. + function taskWorkspaceAdapter(setupResolution: { + kind: string + command?: string + source?: string + decision?: string + }): MountAdapter { + return (context) => { + const useCreate = modules.load< + typeof import('../../tasks/use-mobile-tasks-workspace-create-actions') + >( + 'mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx' + ).useMobileTasksWorkspaceCreateActions + const model = observableModel(context, { + client: context.client, + hostId: 'host-1', + tasksSupported: true, + taskStateHydrated: true, + runtimeTaskSettings: { disabledTuiAgents: ['claude'] }, + trustedOrcaHooks: {}, + workspaceDetectedAgentIds: new Set(['codex']), + workspaceLastAutoName: '', + ensureWorkspaceSshReady: async () => {}, + getWorkspaceTargetRepo: () => ({ + id: 'repo-1', + displayName: 'Repo', + connectionId: 'ssh-1' + }), + resolveCreateSetupDecision: async () => setupResolution, + router: { push: (value: unknown) => context.effect('navigation', value) } + }) + let actions: ReturnType + const hook = hookMount(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. + actions = useCreate(model as unknown as Parameters[0]) + }) + return { + action(name, args) { + if (name === 'mount') { + return hook.mount() + } + if (name === 'submit') { + return actions.createWorkspace( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scenario supplies the action item as JSON, not as a typed model. + (args.item ?? { + key: 'linear:1', + provider: 'linear', + source: { id: 'issue-1' } + }) as Parameters[0], + undefined, + undefined, + 'claude' + ) + } + throw new Error(`Unknown task workspace action: ${name}`) + }, + state: () => + projectObservable({ + settings: model.runtimeTaskSettings, + error: model.error, + creating: model.creatingKey + }), + dispose: hook.unmount + } + } + } + return { 'settings.workspace-submit': (context) => { const useSubmit = modules.load< @@ -56,65 +125,14 @@ export function workspaceSettingsMounts( dispose: hook.unmount } }, - 'settings.task-workspace': (context) => { - const useCreate = modules.load< - typeof import('../../tasks/use-mobile-tasks-workspace-create-actions') - >( - 'mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx' - ).useMobileTasksWorkspaceCreateActions - const model = observableModel(context, { - client: context.client, - hostId: 'host-1', - tasksSupported: true, - taskStateHydrated: true, - runtimeTaskSettings: { disabledTuiAgents: ['claude'] }, - trustedOrcaHooks: {}, - workspaceDetectedAgentIds: new Set(['codex']), - workspaceLastAutoName: '', - ensureWorkspaceSshReady: async () => {}, - getWorkspaceTargetRepo: () => ({ - id: 'repo-1', - displayName: 'Repo', - connectionId: 'ssh-1' - }), - resolveCreateSetupDecision: async () => ({ - kind: 'prompt', - command: 'setup', - source: 'repo' - }), - router: { push: (value: unknown) => context.effect('navigation', value) } - }) - let actions: ReturnType - const hook = hookMount(() => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the members the hook reads. - actions = useCreate(model as unknown as Parameters[0]) - }) - return { - action(name) { - if (name === 'mount') { - return hook.mount() - } - if (name === 'submit') { - return actions.createWorkspace( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scenario supplies the action item as JSON, not as a typed model. - { key: 'linear:1', provider: 'linear', source: { id: 'issue-1' } } as Parameters< - typeof actions.createWorkspace - >[0], - undefined, - undefined, - 'claude' - ) - } - throw new Error(`Unknown task workspace action: ${name}`) - }, - state: () => - projectObservable({ - settings: model.runtimeTaskSettings, - error: model.error, - creating: model.creatingKey - }), - dispose: hook.unmount - } - } + 'settings.task-workspace': taskWorkspaceAdapter({ + kind: 'prompt', + command: 'setup', + source: 'repo' + }), + 'settings.task-workspace-create': taskWorkspaceAdapter({ + kind: 'decision', + decision: 'inherit' + }) } } diff --git a/mobile/src/transport/rpc-operation.ts b/mobile/src/transport/rpc-operation.ts index f4ee18d9dfe..97f27143e93 100644 --- a/mobile/src/transport/rpc-operation.ts +++ b/mobile/src/transport/rpc-operation.ts @@ -247,11 +247,19 @@ export async function interpretAtRpcBarrier< ) as RpcBarrierVerdicts } -/** Preserves omitted sender arguments as well as explicit undefined. */ +/** + * Preserves omitted sender arguments as well as explicit undefined. + * + * A params type with no required field may be omitted too, because the raw port always allowed it + * and several hosts' schemas are entirely optional (`preflight.check`). Forcing `{}` there would + * put a new object on the wire where main sent no params at all. + */ type RpcSendArguments = void extends RpcSendParams ? [params?: RpcSendParams, options?: SendRequestOptions] - : [params: RpcSendParams, options?: SendRequestOptions] + : Record extends RpcSendParams + ? [params?: RpcSendParams, options?: SendRequestOptions] + : [params: RpcSendParams, options?: SendRequestOptions] /** Binds sending and interpretation while preserving the transport promise identity. */ export function bindDeferredRpcOperation< diff --git a/mobile/src/transport/rpc-reader-payload.ts b/mobile/src/transport/rpc-reader-payload.ts index 07cdebc16a5..4d6a8635007 100644 --- a/mobile/src/transport/rpc-reader-payload.ts +++ b/mobile/src/transport/rpc-reader-payload.ts @@ -25,3 +25,11 @@ export function rpcUncheckedPayloadReader( ): RpcCompatibleReader { return (raw) => rpcReadUnchecked(variant, raw) } + +/** One property off the payload, unchecked. The shape for a call site that cast `result.field`. */ +export function rpcUncheckedMemberReader( + variant: Variant, + key: string +): RpcCompatibleReader { + return (raw) => rpcReadUnchecked(variant, rpcPayloadMember(raw, key)) +} diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts index 3e09655c5a1..722c4e58310 100644 --- a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts +++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts @@ -159,14 +159,18 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques // and mobile-git-mutation-operations.ts for the operations the rest of the domain now sends. { file: 'src/source-control/use-mobile-git-requests.ts', references: 1 }, - // src/tasks/ — task lists, filters and mutations - { file: 'src/tasks/composer-source-base-resolve.ts', references: 2 }, + // src/tasks/ — task lists, filters and mutations. The workspace-creation half migrated in + // step 4: create, hosted-base resolution, SSH/agent preflight, sparse presets, the Smart + // source picker's provider reads and the screen's own preference writes. See + // mobile-workspace-create-operations.ts, mobile-workspace-source-operations.ts, + // mobile-task-runtime-operations.ts and mobile-task-source-search-operations.ts. What is left + // is the provider item/detail/mutation half, plus two files that cannot reach zero: + // mobile-tasks-source-family.test-support.ts matches the literal in a source scanner rather + // than sending anything, and use-mobile-tasks-project-file-merge-actions.tsx and + // use-mobile-tasks-hosted-metadata-actions.tsx each multiplex a `{ method, params }` step the + // pickers hand them at runtime. { file: 'src/tasks/mobile-tasks-filter-pickers.tsx', references: 1 }, { file: 'src/tasks/mobile-tasks-source-family.test-support.ts', references: 1 }, - { file: 'src/tasks/setup-hook-trust.ts', references: 1 }, - { file: 'src/tasks/smart-source-paste-intent.ts', references: 4 }, - { file: 'src/tasks/smart-source-search-requests.ts', references: 5 }, - { file: 'src/tasks/use-mobile-tasks-client-settings-actions.tsx', references: 6 }, { file: 'src/tasks/use-mobile-tasks-github-check-file-actions.tsx', references: 5 }, { file: 'src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx', references: 5 }, { file: 'src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx', references: 3 }, @@ -187,16 +191,9 @@ export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcReques { file: 'src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx', references: 3 }, { file: 'src/tasks/use-mobile-tasks-provider-load-actions.tsx', references: 5 }, { file: 'src/tasks/use-mobile-tasks-route-and-item-state.tsx', references: 1 }, - { file: 'src/tasks/use-mobile-tasks-runtime-hydration.tsx', references: 4 }, { file: 'src/tasks/use-mobile-tasks-task-create-actions.tsx', references: 3 }, { file: 'src/tasks/use-mobile-tasks-task-list-loading.tsx', references: 4 }, { file: 'src/tasks/use-mobile-tasks-task-pagination-actions.tsx', references: 1 }, - { file: 'src/tasks/use-mobile-tasks-workspace-create-actions.tsx', references: 3 }, - { file: 'src/tasks/use-mobile-tasks-workspace-source-effects.tsx', references: 2 }, - { file: 'src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx', references: 2 }, - { file: 'src/tasks/use-mobile-tasks-workspace-ssh-state.tsx', references: 5 }, - { file: 'src/tasks/worktree-create-capability.ts', references: 1 }, - { file: 'src/tasks/worktree-create-retry.ts', references: 1 }, // src/terminal/ — terminal input, viewport and queries { file: 'src/terminal/mobile-terminal-query-reply.ts', references: 2 }, From eba56f2f69479f3b78d0b5f6cf663dcd2c4c48ad Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:38:37 -0400 Subject: [PATCH 02/43] feat(ai-vault-search): construct the session search indexer in the scanner service behind a setting (#20516) * feat(ai-vault-search): persist agent-session search consent and retention Two booleans and nothing else: `enabled` and `historyDays`, off by default because building the index reads every transcript on the machine. No `paused` -- the PR 3 indexer is immutable, so every change is close-and-construct. The settings IPC normalizes a write like every other field and hands the change to the index; there is no UI for it until PR 8. * feat(ai-vault-search): hold one indexer and engine pair per host The object that owns a host's live index and the three recipes that change it. The indexer is immutable, so a settings change is close-and-construct, disabling is close with no replacement, and clearing is close, remove the database, construct. The new instance's first sweep purges a narrowed window and admits a widened one, so neither needs a code path. The database sits beside the scanner's parse cache, one file per host. A runtime with no node:sqlite can hold no index at all, which the Node 18 floor on orcad and the relay makes a real case rather than a hypothetical one. * feat(ai-vault): let the scanner child own the session search index The transcript reader runs in that child, so the index consumer has to as well: one read serves both the session list and the index. Three request operations (search, status, reconcile) and one fire-and-forget settings message carry everything a parent needs; main never opens the database file. The init frame becomes a factory because it is read at every spawn, so a respawned child sees current consent rather than the first frame's. A child holding a running index is never idle from the parent's side, so idle retirement is suppressed while the index is on -- retiring it would stop the reconcile loop until some later scan happened to respawn one. Both files this lands in were already at the max-lines ceiling, so three collaborators move to where they belong rather than being disabled around: the invalidation deadline into the class that owns invalidations, call cancellation and the start requeue into the call-state module, and orcad's flag parsing into its own file. * feat(ai-vault-search): register a search service on every host that answers Without a registered service a host answers no-service, which means "this host does not have the feature" rather than "the index is off". All three hosts now answer the second thing. The desktop forwards to the scanner child. orcad and the SSH relay daemon have no such child -- orcad ships only the watcher and daemon entries, and the relay's AI Vault sidecar runs the remote scanner, which publishes nothing to the transcript channel -- so on those two the index lives in the process that would drive its reads, gated on a runtime that has node:sqlite at all. The relay registers with consent off and no way to turn it on: nothing carries a setting to a remote host yet. That is the honest state, and it is still worth registering, because it is what tells a client the difference between off and too old. * test(ai-vault-search): price a warm pass over five thousand transcripts The number the reconcile interval will be revisited against, measured rather than argued: a warm sweep stats every file under every root, a warm cycle stats the newest N per agent, and neither reads what the index already holds. It does not tune the interval. * fix(ai-vault-search): answer the casting gate without assertions main's new type-assertion rule reaches every file this branch touches. All nine sites drop the cast rather than carry a SAFETY: rationale: the operation guard narrows with `in`, the sqlite probe narrows the builtin it loads, the child test keeps the discriminated reply instead of widening it, and the settings resolver takes `unknown` -- which is what it really reads, since a persisted profile can hold a value no version of this code wrote. * fix(ai-vault-search): let a refreshed scan root reach the live index The parent re-resolves scan roots before every policy push, precisely so a WSL distro or extra Codex home that appeared since the child spawned enters the window. The child forwarded only the settings to a live instance and used the roots solely in its `??=` initializer, so those roots were dropped for the child's lifetime. The indexer stays immutable: a structurally different root set closes the pair and constructs a new one, the same way a changed databasePath already does. Compare via `sameSessionSearchRoots` rather than a plain JSON compare, because nothing fixes the key order two producers write; lists are sorted too, since the indexer walks every root and a re-enumeration that reorders is not a change. An unchanged set still never restarts a running index. The orcad and relay in-process hosts resolve roots once at install and never re-apply, so they have no such seam. * fix(ai-vault): restart the scanner child the index is holding Three review items. The hold keeps a child alive for the index, but only a queued call ever started one: `pump()` skipped a hold with an empty queue, so an idle indexing child that crashed, or an `ensureChild()` that failed at start, left indexing stopped until an unrelated request happened to arrive. `pump()` now starts the child the hold requires, which is also the restart callback the fault policy already schedules, so the existing delay and circuit bound the retry exactly as they bound a queued call's start. `updateSessionSearch` goes through the same seam instead of its own `ensureChild` call. A search registers no AbortController, so a cancel sent for a search id was added to the `cancelled` set and never consumed. Nothing can reach that today -- no caller passes a signal and the child answers in milliseconds -- so this is only a leak of ids: consume it when the search settles. The orcad argument doc claimed a `--`-prefixed value stays a flag. The parser takes the next token regardless, and orcad-launch-contract.test.ts pins that, so the doc is what was wrong. Behaviour is unchanged. * fix(ai-vault): recover search indexing and refresh scan roots * fix(ai-vault): defer search refresh policy reads * fix(session-search): stabilize paging and host enablement * fix(session-search): refresh host roots within full sweeps * docs(session-search): clarify initial root fallback --- .../scripts/session-search-pass-benchmark.ts | 93 ++++++++ .../session-search-child-service.test.ts | 55 +++++ .../session-search-child-service.ts | 47 ++++ .../session-search-database-path.ts | 13 ++ .../session-search-enablement.ts | 70 ++++++ .../session-search-host-registration.test.ts | 206 ++++++++++++++++++ .../session-search-in-process-service.ts | 53 +++++ .../session-search-indexer-options.ts | 2 + .../ai-vault-search/session-search-indexer.ts | 12 +- .../session-search-instance.test.ts | 195 +++++++++++++++++ .../session-search-instance.ts | 162 ++++++++++++++ .../ai-vault-search/session-search-policy.ts | 25 +++ .../session-search-retention-policy.ts | 19 +- .../session-search-root-refresh.test.ts | 99 +++++++++ .../session-search-scan-roots.test.ts | 22 +- .../session-search-scan-roots.ts | 27 +++ .../session-search-service-init.ts | 27 +++ .../session-search-service.test.ts | 2 +- .../ai-vault-search/session-search-service.ts | 2 +- .../session-search-sqlite-support.ts | 26 +++ src/main/ai-vault/cached-session-list.ts | 37 ++-- .../session-scanner-service-client-state.ts | 151 +++++++++++-- .../session-scanner-service-client.test.ts | 188 +++++++++++++++- .../session-scanner-service-client.ts | 152 ++++++------- .../ai-vault/session-scanner-service-entry.ts | 20 ++ .../session-scanner-service-protocol.ts | 71 +++++- .../session-scanner-service-restart-policy.ts | 11 +- ...ssion-scanner-service-root-request.test.ts | 57 +++++ .../session-scanner-service-root-request.ts | 40 ++++ ...sion-scanner-service-root-response.test.ts | 63 ++++++ ...ssion-scanner-service-search-roots.test.ts | 106 +++++++++ .../session-scanner-service-search.test.ts | 166 ++++++++++++++ .../session-scanner-service-search.ts | 94 ++++++++ .../ai-vault/session-scanner-service-spawn.ts | 37 +++- .../register-core-handlers.test.ts | 3 +- src/main/ipc/settings.test.ts | 47 ++++ src/main/ipc/settings.ts | 8 + src/main/orcad/orcad-command-arguments.ts | 43 ++++ src/main/orcad/orcad-entry.ts | 47 +--- src/main/orcad/orcad-session-search.ts | 26 +++ .../startup/main-process-runtime-service.ts | 8 + src/relay/relay-runtime-services.ts | 24 +- src/shared/ai-vault-search-settings.test.ts | 81 +++++++ src/shared/ai-vault-search-settings.ts | 65 ++++++ src/shared/global-settings-types.ts | 3 + 45 files changed, 2519 insertions(+), 186 deletions(-) create mode 100644 config/scripts/session-search-pass-benchmark.ts create mode 100644 src/main/ai-vault-search/session-search-child-service.test.ts create mode 100644 src/main/ai-vault-search/session-search-child-service.ts create mode 100644 src/main/ai-vault-search/session-search-database-path.ts create mode 100644 src/main/ai-vault-search/session-search-enablement.ts create mode 100644 src/main/ai-vault-search/session-search-host-registration.test.ts create mode 100644 src/main/ai-vault-search/session-search-in-process-service.ts create mode 100644 src/main/ai-vault-search/session-search-instance.test.ts create mode 100644 src/main/ai-vault-search/session-search-instance.ts create mode 100644 src/main/ai-vault-search/session-search-policy.ts create mode 100644 src/main/ai-vault-search/session-search-root-refresh.test.ts create mode 100644 src/main/ai-vault-search/session-search-service-init.ts create mode 100644 src/main/ai-vault-search/session-search-sqlite-support.ts create mode 100644 src/main/ai-vault/session-scanner-service-root-request.test.ts create mode 100644 src/main/ai-vault/session-scanner-service-root-request.ts create mode 100644 src/main/ai-vault/session-scanner-service-root-response.test.ts create mode 100644 src/main/ai-vault/session-scanner-service-search-roots.test.ts create mode 100644 src/main/ai-vault/session-scanner-service-search.test.ts create mode 100644 src/main/ai-vault/session-scanner-service-search.ts create mode 100644 src/main/orcad/orcad-command-arguments.ts create mode 100644 src/main/orcad/orcad-session-search.ts create mode 100644 src/shared/ai-vault-search-settings.test.ts create mode 100644 src/shared/ai-vault-search-settings.ts diff --git a/config/scripts/session-search-pass-benchmark.ts b/config/scripts/session-search-pass-benchmark.ts new file mode 100644 index 00000000000..6576baa9589 --- /dev/null +++ b/config/scripts/session-search-pass-benchmark.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { isolatedScanRoots } from '../../src/main/ai-vault/session-scanner-test-fixtures' +import { resetSessionParseCacheForTests } from '../../src/main/ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers' +import { SessionSearchIndexer } from '../../src/main/ai-vault-search/session-search-indexer' +import { writeSyntheticTranscriptCorpus } from '../../src/main/ai-vault-search/session-search-synthetic-corpus' + +// Bundle with esbuild --bundle --platform=node, then run on the host under test. +// What a warm pass costs on a machine with a real number of transcripts: a cycle +// stats the newest N per agent, a sweep stats every file under every root, and +// neither reads anything the index already holds at its current stat. This is the +// number the reconcile interval is chosen against; it does not set one. +// Never point this at a real transcript tree. + +const SESSIONS = 5_000 +const TURNS_PER_SESSION = 4 +const PROJECTS = 40 + +const corpus = await writeSyntheticTranscriptCorpus({ + sessions: SESSIONS, + turnsPerSession: TURNS_PER_SESSION +}) +const root = await mkdtemp(join(tmpdir(), 'orca-search-pass-')) +const roots = isolatedScanRoots(root) +const databasePath = join(root, 'index', 'session-search.sqlite') + +try { + // A flat corpus is not what discovery walks: spread it over project directories + // so the readdir count is realistic rather than one enormous listing. + for (let index = 0; index < PROJECTS; index++) { + await mkdir(join(roots.claudeProjectsDir, `project-${index}`), { recursive: true }) + } + await Promise.all( + corpus.files.map((path, index) => + rename(path, join(roots.claudeProjectsDir, `project-${index % PROJECTS}`, basename(path))) + ) + ) + + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + const errors: unknown[] = [] + const indexer = new SessionSearchIndexer({ + databasePath, + roots, + historyDays: null, + // No wall-clock ceiling: the cold build has to finish before a warm pass can + // be measured, and a deadline would leave a backlog priced into every number. + passDeadlineMs: Number.MAX_SAFE_INTEGER, + onError: (error) => errors.push(error) + }) + try { + const coldStarted = performance.now() + await indexer.start() + const coldMs = performance.now() - coldStarted + assert.deepEqual(errors, []) + assert.equal(indexer.status().filesIndexed, SESSIONS, 'indexed file count') + + const sweepStarted = performance.now() + await indexer.reconcile({ full: true }) + const sweepMs = performance.now() - sweepStarted + + const cycleStarted = performance.now() + await indexer.reconcile({ full: false }) + const cycleMs = performance.now() - cycleStarted + + assert.deepEqual(errors, []) + assert.equal(indexer.status().filesDue, 0, 'nothing owed after a warm sweep') + console.log( + JSON.stringify( + { + transcripts: SESSIONS, + projectDirectories: PROJECTS, + transcriptMb: Math.round((corpus.transcriptBytes / (1024 * 1024)) * 100) / 100, + coldBuildMs: Math.round(coldMs), + warmSweepMs: Math.round(sweepMs), + warmCycleMs: Math.round(cycleMs) + }, + null, + 2 + ) + ) + } finally { + indexer.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + } +} finally { + await rm(corpus.root, { recursive: true, force: true }) + await rm(root, { recursive: true, force: true }) +} diff --git a/src/main/ai-vault-search/session-search-child-service.test.ts b/src/main/ai-vault-search/session-search-child-service.test.ts new file mode 100644 index 00000000000..ddd3e8cb03b --- /dev/null +++ b/src/main/ai-vault-search/session-search-child-service.test.ts @@ -0,0 +1,55 @@ +import { expect, it, vi } from 'vitest' +import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' +import type { AiVaultSearchStatus } from '../../shared/ai-vault-search-types' +import { createChildSessionSearchService } from './session-search-child-service' + +const indexingStatus: AiVaultSearchStatus = { + ...unavailableSessionSearchStatus(), + enabled: true, + phase: 'indexing', + filesIndexed: 3, + generation: 7 +} + +function stubCalls(overrides: Partial[0]> = {}) { + return { + search: vi.fn(async () => ({ kind: 'unavailable', reason: 'disabled' }) as const), + status: vi.fn(async () => indexingStatus), + reconcile: vi.fn(async () => undefined), + ...overrides + } +} + +it('forwards every call to the child and returns what it answered', async () => { + const calls = stubCalls() + const service = createChildSessionSearchService(calls) + + expect(await service.search({ query: 'ledger' })).toEqual({ + kind: 'unavailable', + reason: 'disabled' + }) + expect(calls.search).toHaveBeenCalledWith({ query: 'ledger' }) + expect(await service.status()).toEqual(indexingStatus) + await service.reconcile() + expect(calls.reconcile).toHaveBeenCalledTimes(1) +}) + +// A child that is starting, restarting or refusing is "not yet", which is an +// answer to the caller's question; turning it into a throw would make a paired +// client show a transport error for a host that is simply booting. +it('maps a child that cannot answer to not-ready rather than an error', async () => { + const service = createChildSessionSearchService( + stubCalls({ + search: vi.fn(() => Promise.reject(new Error('AI Vault service did not become ready.'))), + status: vi.fn(() => Promise.reject(new Error('AI Vault service queue is full.'))), + reconcile: vi.fn(() => Promise.reject(new Error('AI Vault service disconnected.'))) + }) + ) + + expect(await service.search({ query: 'ledger' })).toEqual({ + kind: 'unavailable', + reason: 'not-ready' + }) + expect(await service.status()).toEqual(unavailableSessionSearchStatus()) + await expect(service.reconcile()).resolves.toBeUndefined() +}) diff --git a/src/main/ai-vault-search/session-search-child-service.ts b/src/main/ai-vault-search/session-search-child-service.ts new file mode 100644 index 00000000000..379efeff4b1 --- /dev/null +++ b/src/main/ai-vault-search/session-search-child-service.ts @@ -0,0 +1,47 @@ +import type { AiVaultSearchStatus } from '../../shared/ai-vault-search-types' +import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' +import { + reconcileSessionSearchInService, + searchSessionsInService, + sessionSearchStatusInService +} from '../ai-vault/session-scanner-service-spawn' +import type { SessionSearchService } from './session-search-service' + +/** + * The desktop's `SessionSearchService`: every call is forwarded to the scanner + * child that owns the database. This process never opens the index file. + * + * A transport failure is a child that is starting, restarting or refusing, which + * is `not-ready` rather than an error: the caller asked whether this host can + * answer, and "not yet" is an answer. A child that is up and has no indexer says + * `disabled` for itself. + */ +export function createChildSessionSearchService( + calls = { + search: searchSessionsInService, + status: sessionSearchStatusInService, + reconcile: reconcileSessionSearchInService + } +): SessionSearchService { + return { + search: async (request) => { + try { + return await calls.search(request) + } catch { + return { kind: 'unavailable', reason: 'not-ready' } + } + }, + status: async (): Promise => { + try { + return await calls.status() + } catch { + return unavailableSessionSearchStatus() + } + }, + reconcile: async () => { + // Swallowed for the same reason: the caller's next search reports the state + // of the index, and a freshness wait that cannot run is a stale page, not a throw. + await calls.reconcile().catch(() => undefined) + } + } +} diff --git a/src/main/ai-vault-search/session-search-database-path.ts b/src/main/ai-vault-search/session-search-database-path.ts new file mode 100644 index 00000000000..5fcaf405f57 --- /dev/null +++ b/src/main/ai-vault-search/session-search-database-path.ts @@ -0,0 +1,13 @@ +import { join } from 'node:path' + +/** + * Where one host keeps its index. + * + * Beside the scanner's parse cache (`/ai-vault/`), because the two are + * the same kind of thing: a disposable derivative of the transcripts this host + * can read, scoped to this host's data root. One file per host, never shared — + * a second process writing the same file is the rebuild race PR 2 recorded. + */ +export function sessionSearchDatabasePath(dataRoot: string): string { + return join(dataRoot, 'ai-vault', 'session-search.sqlite') +} diff --git a/src/main/ai-vault-search/session-search-enablement.ts b/src/main/ai-vault-search/session-search-enablement.ts new file mode 100644 index 00000000000..e292e4a8551 --- /dev/null +++ b/src/main/ai-vault-search/session-search-enablement.ts @@ -0,0 +1,70 @@ +import { + resolveAiVaultSearchSettings, + sameAiVaultSearchSettings +} from '../../shared/ai-vault-search-settings' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { updateSessionSearchInService } from '../ai-vault/session-scanner-service-spawn' +import { createChildSessionSearchService } from './session-search-child-service' +import { installSessionSearchPolicySource } from './session-search-policy' +import { setSessionSearchService } from './session-search-service-registry' +import { + installSessionSearchDataRoot, + sessionSearchServiceInit +} from './session-search-service-init' +import { sessionSearchSqliteAvailable } from './session-search-sqlite-support' +let installed = false + +/** + * The desktop's one wiring point: search answers from the scanner child, and the + * child's consent comes from the settings store. + * + * Registered whether or not the setting is on, because "off" is an answer this + * host can give (`unavailable/disabled`) and `no-service` is not — that reason + * means nothing here owns an index, which stops being true the moment this runs. + */ +export function installChildSessionSearchService(args: { + dataRoot: string + getSettings: () => Pick +}): { dispose(): void } | null { + if (!sessionSearchSqliteAvailable()) { + return null + } + installed = true + installSessionSearchDataRoot(args.dataRoot) + installSessionSearchPolicySource(args.getSettings) + setSessionSearchService(createChildSessionSearchService()) + pushSessionSearchPolicy() + return { + dispose: () => { + installed = false + } + } +} + +/** + * Reconciles a settings write. An unchanged policy is not forwarded, so re-saving + * the same value never restarts a running index. + */ +export function applySessionSearchSettingsChange( + before: Pick, + after: Pick +): void { + if ( + sameAiVaultSearchSettings( + resolveAiVaultSearchSettings(before), + resolveAiVaultSearchSettings(after) + ) + ) { + return + } + if (installed) { + pushSessionSearchPolicy() + } +} + +function pushSessionSearchPolicy(): void { + const init = sessionSearchServiceInit() + if (init) { + updateSessionSearchInService(init) + } +} diff --git a/src/main/ai-vault-search/session-search-host-registration.test.ts b/src/main/ai-vault-search/session-search-host-registration.test.ts new file mode 100644 index 00000000000..0e42ac68cde --- /dev/null +++ b/src/main/ai-vault-search/session-search-host-registration.test.ts @@ -0,0 +1,206 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { installInProcessSessionSearchService } from './session-search-in-process-service' +import { + openSessionSearchIndexerHarness, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' +import { searchSessionService } from './session-search-service-registry' +import { resetSessionSearchPolicyForTests } from './session-search-policy' +import { resetSessionSearchServiceInitForTests } from './session-search-service-init' + +/** + * Every host that answers a search has to register a service, or its answer is + * `no-service` — which means "this host does not have the feature", not "it is + * off". Two halves: the installers really register, and each host's boot module + * really calls the installer that suits it. + */ + +const updateSessionSearchInService = vi.hoisted(() => vi.fn()) +vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + updateSessionSearchInService +})) + +const localAiVaultScanRoots = vi.hoisted(() => vi.fn()) +vi.mock('../ai-vault/cached-session-list', async (importOriginal) => ({ + ...(await importOriginal()), + localAiVaultScanRoots +})) + +const ROOT = join(import.meta.dirname, '..', '..', '..') + +let harness: SessionSearchIndexerHarness +let installed: { dispose(): void } | null + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + updateSessionSearchInService.mockClear() + harness = await openSessionSearchIndexerHarness('ss-registration') + installed = null + localAiVaultScanRoots.mockReset().mockResolvedValue(harness.roots) +}) + +afterEach(async () => { + installed?.dispose() + vi.useRealTimers() + const { setSessionSearchService } = await import('./session-search-service-registry') + setSessionSearchService(null) + resetSessionSearchPolicyForTests() + resetSessionSearchServiceInitForTests() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +it('answers no-service until a host registers one', async () => { + expect(await searchSessionService({ query: 'ledger' }, 'ipc')).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) +}) + +it('registers the desktop service and pushes the stored policy at boot', async () => { + const { installChildSessionSearchService } = await import('./session-search-enablement') + installed = installChildSessionSearchService({ + dataRoot: harness.root, + getSettings: () => ({ aiVaultSearch: { enabled: true, historyDays: 30 } }) + }) + + expect(await searchSessionService({ query: 'ledger' }, 'ipc')).not.toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + await vi.waitFor(() => expect(updateSessionSearchInService).toHaveBeenCalledTimes(1)) + expect(updateSessionSearchInService.mock.calls[0]?.[0]).toMatchObject({ + settings: { enabled: true, historyDays: 30 }, + databasePath: join(harness.root, 'ai-vault', 'session-search.sqlite') + }) +}) + +it('forwards only a real settings change to the child', async () => { + const { applySessionSearchSettingsChange, installChildSessionSearchService } = + await import('./session-search-enablement') + installed = installChildSessionSearchService({ + dataRoot: harness.root, + getSettings: () => ({ aiVaultSearch: { enabled: false, historyDays: null } }) + }) + await vi.waitFor(() => expect(updateSessionSearchInService).toHaveBeenCalledTimes(1)) + + applySessionSearchSettingsChange( + { aiVaultSearch: { enabled: false, historyDays: null } }, + { aiVaultSearch: { enabled: false, historyDays: null } } + ) + expect(updateSessionSearchInService).toHaveBeenCalledTimes(1) + + applySessionSearchSettingsChange( + { aiVaultSearch: { enabled: false, historyDays: null } }, + { aiVaultSearch: { enabled: true, historyDays: null } } + ) + await vi.waitFor(() => expect(updateSessionSearchInService).toHaveBeenCalledTimes(2)) +}) + +it('does not discover roots or arm a timer during registration', async () => { + vi.useFakeTimers() + const { installChildSessionSearchService } = await import('./session-search-enablement') + installed = installChildSessionSearchService({ + dataRoot: harness.root, + getSettings: () => ({ aiVaultSearch: { enabled: false, historyDays: null } }) + }) + expect(updateSessionSearchInService).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(600_000) + expect(localAiVaultScanRoots).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) +}) + +it('registers an in-process service for a host with no scanner child', async () => { + installed = installInProcessSessionSearchService({ + dataRoot: harness.root, + roots: harness.roots, + settings: { enabled: false, historyDays: null } + }) + expect(installed).not.toBeNull() + + // Off, not absent: the caller can tell consent from a host that lacks the feature. + expect(await searchSessionService({ query: 'ledger' }, 'relay')).toEqual({ + kind: 'unavailable', + reason: 'disabled' + }) + + installed?.dispose() + installed = null + expect(await searchSessionService({ query: 'ledger' }, 'relay')).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) +}) + +// The behavioural tests above prove the installers register; these prove each +// host's boot path reaches one, which no unit of either module can show. +it.each([ + [ + 'desktop and headless serve', + 'src/main/startup/main-process-runtime-service.ts', + 'installChildSessionSearchService' + ], + ['orcad', 'src/main/orcad/orcad-session-search.ts', 'installInProcessSessionSearchService'], + [ + 'the relay daemon', + 'src/relay/relay-runtime-services.ts', + 'installInProcessSessionSearchService' + ] +])('boots %s with a registered session search service', (_host, file, installer) => { + const source = readFileSync(join(ROOT, file), 'utf8') + expect(source).toContain(installer) + expect(source).toMatch(new RegExp(`${installer}\\(\\{`)) +}) + +it('disables immediately without root discovery', async () => { + const { installChildSessionSearchService, applySessionSearchSettingsChange } = + await import('./session-search-enablement') + let settings = { aiVaultSearch: { enabled: true, historyDays: null } } + installed = installChildSessionSearchService({ + dataRoot: harness.root, + getSettings: () => settings + }) + updateSessionSearchInService.mockClear() + const before = settings + settings = { aiVaultSearch: { enabled: false, historyDays: null } } + applySessionSearchSettingsChange(before, settings) + expect(updateSessionSearchInService).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ settings: settings.aiVaultSearch }) + ) + expect(localAiVaultScanRoots).not.toHaveBeenCalled() +}) + +it('orcad resolves no roots while disabled and discovers late roots when enabled', async () => { + const { installOrcadSessionSearchService } = await import('../orcad/orcad-session-search') + installed = await installOrcadSessionSearchService({ + userDataPath: harness.root, + getSettings: () => ({ aiVaultSearch: { enabled: false, historyDays: null } }) + }) + expect(localAiVaultScanRoots).not.toHaveBeenCalled() + installed?.dispose() + installed = await installOrcadSessionSearchService({ + userDataPath: harness.root, + getSettings: () => ({ aiVaultSearch: { enabled: true, historyDays: null } }) + }) + await searchSessionService({ query: 'latehostroot', freshness: 'wait-until-current' }, 'ipc') + const late = join(harness.root, 'late-claude') + const id = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + await writeClaudeTranscript(join(late, 'project', `${id}.jsonl`), ['latehostroot'], id) + localAiVaultScanRoots.mockResolvedValue({ ...harness.roots, claudeProjectsDir: late }) + const response = await searchSessionService( + { query: 'latehostroot', freshness: 'wait-until-current' }, + 'ipc' + ) + expect(response.kind).toBe('results') + if (response.kind === 'results') { + expect(response.hits.map((hit) => hit.sessionId)).toEqual([id]) + } +}) diff --git a/src/main/ai-vault-search/session-search-in-process-service.ts b/src/main/ai-vault-search/session-search-in-process-service.ts new file mode 100644 index 00000000000..536ad742a98 --- /dev/null +++ b/src/main/ai-vault-search/session-search-in-process-service.ts @@ -0,0 +1,53 @@ +import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' +import { sessionSearchDatabasePath } from './session-search-database-path' +import type { SessionSearchIndexerOptions } from './session-search-indexer-options' +import { SessionSearchInstance } from './session-search-instance' +import type { SessionSearchScanRoots } from './session-search-scan-roots' +import { setSessionSearchService } from './session-search-service-registry' +import { sessionSearchSqliteAvailable } from './session-search-sqlite-support' + +/** + * Registration for the two hosts that have no scanner-service child of their own. + * + * The desktop puts the index in that child because the child is where the + * transcript reader runs, so one read serves both the session list and the index. + * Neither of these hosts has that child: orcad ships only the watcher and daemon + * entries beside `orcad.js`, and the relay's AI Vault sidecar runs the remote + * scanner, which reads through a filesystem provider and publishes nothing to the + * transcript channel. On both, the process that would drive the index's reads is + * this one, and it is the only writer, so the two-process rebuild race the + * desktop rule avoids cannot arise here. + * + * Returns null on a runtime with no `node:sqlite`: both hosts are built for a + * Node 18 floor, and a host that cannot hold an index registers nothing rather + * than answering `disabled` for a reason that is not consent. + */ +export function installInProcessSessionSearchService(args: { + dataRoot: string + roots: SessionSearchScanRoots + resolveRoots?: SessionSearchIndexerOptions['resolveRoots'] + settings: AiVaultSearchSettings + onError?: (error: unknown) => void +}): { dispose(): void } | null { + if (!sessionSearchSqliteAvailable()) { + return null + } + const instance = new SessionSearchInstance({ + databasePath: sessionSearchDatabasePath(args.dataRoot), + roots: args.roots, + resolveRoots: args.resolveRoots, + ...(args.onError ? { onError: args.onError } : {}) + }) + instance.apply(args.settings) + setSessionSearchService({ + search: (request) => instance.search(request), + status: async () => instance.status(), + reconcile: () => instance.reconcile() + }) + return { + dispose: () => { + setSessionSearchService(null) + instance.close() + } + } +} diff --git a/src/main/ai-vault-search/session-search-indexer-options.ts b/src/main/ai-vault-search/session-search-indexer-options.ts index 08eb4aeb2af..2c32c8381da 100644 --- a/src/main/ai-vault-search/session-search-indexer-options.ts +++ b/src/main/ai-vault-search/session-search-indexer-options.ts @@ -36,6 +36,8 @@ export const DEFAULT_SESSION_SEARCH_FULL_SWEEP_EVERY_CYCLES = 15 export type SessionSearchIndexerOptions = { databasePath: string roots: SessionSearchScanRoots + /** Full sweeps refresh host roots; recent cycles reuse the last snapshot. */ + resolveRoots?: (signal: AbortSignal) => Promise /** null = all history; otherwise only transcripts modified within this many days. */ historyDays: number | null clock?: SessionSearchClock diff --git a/src/main/ai-vault-search/session-search-indexer.ts b/src/main/ai-vault-search/session-search-indexer.ts index 26213242c49..d70d3718f9c 100644 --- a/src/main/ai-vault-search/session-search-indexer.ts +++ b/src/main/ai-vault-search/session-search-indexer.ts @@ -58,6 +58,7 @@ export type SessionSearchIndexStatus = { * counter with a reset rule. * * What is left here, and why none of it can be a row: + * - `roots`, the latest full-sweep snapshot reused by recent cycles. * - `previousRootsWithFiles`, the one bit per root the retirement walk's grace * needs. Deliberately not durable: see the mountpoint trade in * `session-search-deleted-sources.ts`. @@ -81,6 +82,7 @@ export type SessionSearchIndexStatus = { * one reconcile interval. Everything else is reached by the periodic sweep. */ export class SessionSearchIndexer { + private roots: SessionSearchIndexerOptions['roots'] private readonly ownershipPath: string private readonly clock: SessionSearchClock private readonly intervalMs: number @@ -104,6 +106,7 @@ export class SessionSearchIndexer { private closed = false constructor(private readonly options: SessionSearchIndexerOptions) { + this.roots = options.roots this.ownershipPath = resolve(options.databasePath) this.clock = options.clock ?? systemSessionSearchClock this.intervalMs = options.reconcileIntervalMs ?? DEFAULT_SESSION_SEARCH_RECONCILE_INTERVAL_MS @@ -273,9 +276,16 @@ export class SessionSearchIndexer { // end would erase that request along with this pass's own. this.sweepNext = false try { + if (full && this.options.resolveRoots) { + const roots = await this.options.resolveRoots(signal) + if (signal.aborted) { + return + } + this.roots = roots + } const result = await runSessionSearchPass({ store: this.store, - roots: this.options.roots, + roots: this.roots, full, recentPerAgent: this.recentPerAgent, previousRootsWithFiles: this.previousRootsWithFiles ?? undefined, diff --git a/src/main/ai-vault-search/session-search-instance.test.ts b/src/main/ai-vault-search/session-search-instance.test.ts new file mode 100644 index 00000000000..c917bd2d8a9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-instance.test.ts @@ -0,0 +1,195 @@ +import { existsSync } from 'node:fs' +import { utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import { SessionSearchInstance } from './session-search-instance' +import { + openSessionSearchIndexerHarness, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +const RECENT_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const ANCIENT_SESSION_ID = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + +let harness: SessionSearchIndexerHarness +let instance: SessionSearchInstance | null +let errors: unknown[] + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + errors = [] + harness = await openSessionSearchIndexerHarness('ss-instance') + instance = null +}) + +afterEach(async () => { + vi.restoreAllMocks() + instance?.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +function newInstance(): SessionSearchInstance { + instance = new SessionSearchInstance({ + databasePath: harness.databasePath, + roots: harness.roots, + onError: (error) => errors.push(error) + }) + return instance +} + +function transcriptPath(sessionId: string): string { + return join(harness.claudeProjectDir, `${sessionId}.jsonl`) +} + +async function searchFor(query: string): Promise { + const response = await instance!.search({ query }) + if (response.kind !== 'results') { + throw new Error(`expected results, got ${response.kind}`) + } + return response.hits.map((hit) => hit.sessionId).sort() +} + +it('constructs nothing and touches no disk while the setting is off', async () => { + await writeClaudeTranscript( + transcriptPath(RECENT_SESSION_ID), + ['a conversation'], + RECENT_SESSION_ID + ) + const subject = newInstance() + subject.apply({ enabled: false, historyDays: null }) + await subject.settled() + + expect(subject.running).toBe(false) + expect(existsSync(harness.databasePath)).toBe(false) + expect(await subject.search({ query: 'conversation' })).toEqual({ + kind: 'unavailable', + reason: 'disabled' + }) + expect(subject.status()).toMatchObject({ enabled: false, phase: 'idle', generation: 0 }) + expect(errors).toEqual([]) +}) + +it('indexes and answers once the setting is on', async () => { + await writeClaudeTranscript( + transcriptPath(RECENT_SESSION_ID), + ['a distinctive conversation'], + RECENT_SESSION_ID + ) + const subject = newInstance() + subject.apply({ enabled: true, historyDays: null }) + await subject.settled() + + expect(await searchFor('distinctive')).toEqual([RECENT_SESSION_ID]) + const status = subject.status() + expect(status.enabled).toBe(true) + expect(status.filesIndexed).toBeGreaterThan(0) + expect(status.generation).toBeGreaterThan(0) + expect(errors).toEqual([]) +}) + +// The whole reason the indexer is immutable: a change is a new instance, and the +// old one is closed before it exists, so there is never a second writer. +it('closes the live pair and starts a new one on a settings change', async () => { + const recent = transcriptPath(RECENT_SESSION_ID) + const ancient = transcriptPath(ANCIENT_SESSION_ID) + await writeClaudeTranscript(recent, ['a recent conversation'], RECENT_SESSION_ID) + await writeClaudeTranscript(ancient, ['an ancient conversation'], ANCIENT_SESSION_ID) + const longAgo = new Date(Date.now() - 120 * 86_400_000) + await utimes(ancient, longAgo, longAgo) + + const subject = newInstance() + subject.apply({ enabled: true, historyDays: null }) + await subject.settled() + expect(await searchFor('ancient')).toEqual([ANCIENT_SESSION_ID]) + + // Narrowing: the new instance's opening sweep purges what the window no longer covers. + subject.apply({ enabled: true, historyDays: 30 }) + await subject.settled() + expect(await searchFor('ancient')).toEqual([]) + expect(await searchFor('recent')).toEqual([RECENT_SESSION_ID]) + + // Widening: the same recipe the other way, admitting files no read ever saw. + subject.apply({ enabled: true, historyDays: null }) + await subject.settled() + expect(await searchFor('ancient')).toEqual([ANCIENT_SESSION_ID]) + expect(errors).toEqual([]) +}) + +it('leaves nothing running and no live claim when the setting goes off', async () => { + await writeClaudeTranscript( + transcriptPath(RECENT_SESSION_ID), + ['a conversation'], + RECENT_SESSION_ID + ) + const subject = newInstance() + subject.apply({ enabled: true, historyDays: null }) + await subject.settled() + expect(subject.running).toBe(true) + + subject.apply({ enabled: false, historyDays: null }) + expect(subject.running).toBe(false) + // The index is left on disk: disabling is not a deletion, and the claim the + // closed indexer staked on the path has to be released or nothing can reopen it. + expect(existsSync(harness.databasePath)).toBe(true) + subject.apply({ enabled: true, historyDays: null }) + await subject.settled() + expect(subject.running).toBe(true) + expect(errors).toEqual([]) +}) + +it('removes the database on clear and rebuilds only while consent stands', async () => { + await writeClaudeTranscript( + transcriptPath(RECENT_SESSION_ID), + ['a distinctive conversation'], + RECENT_SESSION_ID + ) + const subject = newInstance() + subject.apply({ enabled: true, historyDays: null }) + await subject.settled() + expect(await searchFor('distinctive')).toEqual([RECENT_SESSION_ID]) + + subject.clear() + expect(subject.running).toBe(true) + expect(existsSync(harness.databasePath)).toBe(true) + await subject.settled() + expect(await searchFor('distinctive')).toEqual([RECENT_SESSION_ID]) + + subject.apply({ enabled: false, historyDays: null }) + subject.clear() + expect(subject.running).toBe(false) + expect(existsSync(harness.databasePath)).toBe(false) + expect(errors).toEqual([]) +}) + +it('keeps pagination stable when the clock crosses retention before a purge', async () => { + for (const id of [RECENT_SESSION_ID, ANCIENT_SESSION_ID]) { + await writeClaudeTranscript(transcriptPath(id), [`distinctive conversation ${id}`], id) + } + const subject = newInstance() + subject.apply({ enabled: true, historyDays: 30 }) + await subject.settled() + const first = await subject.search({ query: 'distinctive', limit: 1 }) + if (first.kind !== 'results') { + throw new Error('expected results') + } + expect(first.page.cursor).toBeTruthy() + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 31 * 86_400_000) + const second = await subject.search({ + query: 'distinctive', + limit: 1, + cursor: first.page.cursor! + }) + if (second.kind !== 'results') { + throw new Error('expected results') + } + expect(second.generation).toBe(first.generation) + expect(second.hits).toHaveLength(1) + expect(second.hits[0].sessionId).not.toBe(first.hits[0].sessionId) + expect(errors).toEqual([]) +}) diff --git a/src/main/ai-vault-search/session-search-instance.ts b/src/main/ai-vault-search/session-search-instance.ts new file mode 100644 index 00000000000..b17103b004b --- /dev/null +++ b/src/main/ai-vault-search/session-search-instance.ts @@ -0,0 +1,162 @@ +import type SyncDatabase from '../sqlite/sync-database' +import type { + AiVaultSearchRequest, + AiVaultSearchResponse, + AiVaultSearchStatus +} from '../../shared/ai-vault-search-types' +import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' +import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' +import { SessionSearchEngine } from './session-search-engine' +import { SessionSearchIndexer } from './session-search-indexer' +import { sessionSearchHistoryCutoffMs } from './session-search-retention-policy' +import { openSessionSearchDatabase, removeSessionSearchDatabase } from './session-search-schema' +import type { SessionSearchScanRoots } from './session-search-scan-roots' +import type { SessionSearchIndexerOptions } from './session-search-indexer-options' +import { createSessionSearchService, type SessionSearchService } from './session-search-service' + +export type SessionSearchInstanceOptions = { + databasePath: string + roots: SessionSearchScanRoots + resolveRoots?: SessionSearchIndexerOptions['resolveRoots'] + onError?: (error: unknown) => void + /** Tests only: shortens the loop so a settings change is observable in one tick. */ + reconcileIntervalMs?: number +} + +type LiveIndex = { + indexer: SessionSearchIndexer + engine: SessionSearchEngine + /** The engine's own handle; the indexer's store keeps a second, private one. */ + db: SyncDatabase + service: SessionSearchService +} + +/** + * The one object that holds a host's live indexer and engine, and the three + * recipes that change them. + * + * The indexer is immutable after construction, so there is nothing here that + * reconfigures one: a settings change is `close()` and a new instance, disabling + * is `close()` with no replacement, and clearing is `close()`, remove the + * database, construct again. The new instance's first sweep purges a narrowed + * window and admits a widened one, so neither of those needs a path of its own. + * + * Lives in whichever process runs the transcript reader for this host. Nothing + * here knows about IPC, Electron or a settings store; the caller supplies the + * resolved settings and scan roots. + */ +export class SessionSearchInstance { + private live: LiveIndex | null = null + private settings: AiVaultSearchSettings = { enabled: false, historyDays: null } + private readonly onError: (error: unknown) => void + + constructor(private readonly options: SessionSearchInstanceOptions) { + this.onError = options.onError ?? ((error) => console.warn('[ai-vault-search]', error)) + } + + /** True once an indexer exists; false while disabled or while a construction is failing. */ + get running(): boolean { + return this.live !== null + } + + /** Close whatever is live and construct from `next`. A no-op change still restarts. */ + apply(next: AiVaultSearchSettings): void { + this.settings = next + this.closeLive() + this.construct() + } + + /** Throw the index away, then rebuild it if consent still stands. */ + clear(): void { + this.closeLive() + removeSessionSearchDatabase(this.options.databasePath) + this.construct() + } + + close(): void { + this.closeLive() + } + + async search(request: AiVaultSearchRequest): Promise { + const live = this.live + if (!live) { + return { kind: 'unavailable', reason: this.settings.enabled ? 'not-ready' : 'disabled' } + } + return live.service.search(request) + } + + status(): AiVaultSearchStatus { + const live = this.live + if (!live) { + return { ...unavailableSessionSearchStatus(), enabled: this.settings.enabled } + } + return { + enabled: true, + ...live.indexer.status(), + generation: live.engine.generation() + } + } + + async reconcile(): Promise { + await this.live?.service.reconcile() + } + + /** Tests only: resolves once the work loop has no pass in flight. */ + settled(): Promise { + return this.live?.indexer.settled() ?? Promise.resolve() + } + + private construct(): void { + if (!this.settings.enabled) { + return + } + const { historyDays } = this.settings + let indexer: SessionSearchIndexer | null = null + let db: SyncDatabase | null = null + try { + indexer = new SessionSearchIndexer({ + databasePath: this.options.databasePath, + roots: this.options.roots, + resolveRoots: this.options.resolveRoots, + historyDays, + onError: this.onError, + ...(this.options.reconcileIntervalMs === undefined + ? {} + : { reconcileIntervalMs: this.options.reconcileIntervalMs }) + }) + db = openSessionSearchDatabase(this.options.databasePath) + // Later expiry comes from the indexer purge, which also invalidates page cursors. + const engineOptions = { + retentionCutoffMs: sessionSearchHistoryCutoffMs(historyDays, Date.now()) + } + const engine = new SessionSearchEngine(db, engineOptions) + this.live = { + indexer, + engine, + db, + service: createSessionSearchService({ engine, indexer }) + } + void indexer.start().catch(this.onError) + } catch (error) { + // A failed open must leave nothing half-built: the indexer stakes the + // database path when its store opens, and only close() releases it. + db?.close() + indexer?.close() + this.live = null + this.onError(error) + } + } + + private closeLive(): void { + const live = this.live + this.live = null + if (!live) { + return + } + try { + live.indexer.close() + } finally { + live.db.close() + } + } +} diff --git a/src/main/ai-vault-search/session-search-policy.ts b/src/main/ai-vault-search/session-search-policy.ts new file mode 100644 index 00000000000..9f706f35fea --- /dev/null +++ b/src/main/ai-vault-search/session-search-policy.ts @@ -0,0 +1,25 @@ +import { + DEFAULT_AI_VAULT_SEARCH_SETTINGS, + resolveAiVaultSearchSettings, + type AiVaultSearchSettings +} from '../../shared/ai-vault-search-settings' +import type { GlobalSettings } from '../../shared/global-settings-types' + +// Why a source and not a captured value: the scanner child is spawned lazily and +// respawned after a fault, so its init frame has to read consent at spawn time. +// Before a composition root installs one, every read is the safe default (off). +let readSettings: (() => AiVaultSearchSettings) | null = null + +export function installSessionSearchPolicySource( + source: (() => Pick) | null +): void { + readSettings = source ? () => resolveAiVaultSearchSettings(source()) : null +} + +export function sessionSearchPolicy(): AiVaultSearchSettings { + return readSettings?.() ?? DEFAULT_AI_VAULT_SEARCH_SETTINGS +} + +export function resetSessionSearchPolicyForTests(): void { + readSettings = null +} diff --git a/src/main/ai-vault-search/session-search-retention-policy.ts b/src/main/ai-vault-search/session-search-retention-policy.ts index c7fa8a0b6d1..f6b7c483d48 100644 --- a/src/main/ai-vault-search/session-search-retention-policy.ts +++ b/src/main/ai-vault-search/session-search-retention-policy.ts @@ -1,25 +1,12 @@ -const DAY_MS = 86_400_000 -const HISTORY_DAYS_MAX = 3_650 +import { normalizeAiVaultSearchHistoryDays } from '../../shared/ai-vault-search-settings' -/** - * The retention window, as the indexer's callers state it and as the store - * consumes it. Settings storage is PR 3b's problem; this is the arithmetic. - */ -function normalizeSessionSearchHistoryDays(value: number | null): number | null { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { - return null - } - // Why floor then re-check: a fractional day floors to 0, which reads as "all - // history" on one side and "now" on the other; make the two agree. - const days = Math.floor(value) - return days <= 0 ? null : Math.min(HISTORY_DAYS_MAX, days) -} +const DAY_MS = 86_400_000 /** The oldest transcript mtime worth indexing; null means no bound. */ export function sessionSearchHistoryCutoffMs( historyDays: number | null, nowMs: number ): number | null { - const days = normalizeSessionSearchHistoryDays(historyDays) + const days = normalizeAiVaultSearchHistoryDays(historyDays) return days === null ? null : nowMs - days * DAY_MS } diff --git a/src/main/ai-vault-search/session-search-root-refresh.test.ts b/src/main/ai-vault-search/session-search-root-refresh.test.ts new file mode 100644 index 00000000000..43d57e19536 --- /dev/null +++ b/src/main/ai-vault-search/session-search-root-refresh.test.ts @@ -0,0 +1,99 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { SessionSearchIndexer } from './session-search-indexer' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' + +let harness: SessionSearchIndexerHarness +let indexer: SessionSearchIndexer | undefined +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + harness = await openSessionSearchIndexerHarness('search-root-refresh') +}) +afterEach(async () => { + indexer?.close() + indexer = undefined + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + await harness.cleanup() +}) + +it('refreshes roots on scheduled full sweeps and reuses them on recent cycles', async () => { + const clock = new FakeSessionSearchClock() + let roots = harness.roots + const resolveRoots = vi.fn(async () => roots) + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots, + resolveRoots, + historyDays: null, + clock, + fullSweepEveryCycles: 1 + }) + await indexer.start() + expect(resolveRoots).toHaveBeenCalledTimes(1) + const newRoot = join(harness.root, 'late') + const id = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + await writeClaudeTranscript(join(newRoot, 'project', `${id}.jsonl`), ['a late conversation'], id) + roots = { ...roots, claudeProjectsDir: newRoot } + clock.advance(20_000) + await indexer.settled() + expect(resolveRoots).toHaveBeenCalledTimes(1) + expect(indexer.status().filesIndexed).toBe(0) + clock.advance(20_000) + await indexer.settled() + expect(resolveRoots).toHaveBeenCalledTimes(2) + expect(indexer.status().filesIndexed).toBe(1) + clock.advance(20_000) + await indexer.settled() + expect(resolveRoots).toHaveBeenCalledTimes(2) + expect(indexer.status().filesIndexed).toBe(1) +}) + +it('does not access a closed store when pending discovery completes', async () => { + const pending = Promise.withResolvers() + const errors = vi.fn() + const resolver = vi.fn(() => pending.promise) + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + resolveRoots: resolver, + historyDays: null, + onError: errors + }) + const start = indexer.start() + await vi.waitFor(() => expect(resolver).toHaveBeenCalledTimes(1)) + indexer.close() + pending.resolve(harness.roots) + await start + expect(errors).not.toHaveBeenCalled() + expect(indexer.status().filesIndexed).toBe(0) +}) + +it('retries discovery after failure without silently sweeping stale roots', async () => { + const errors = vi.fn() + const resolveRoots = vi + .fn() + .mockRejectedValueOnce(new Error('unavailable')) + .mockResolvedValue(harness.roots) + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + resolveRoots, + historyDays: null, + onError: errors + }) + await indexer.start() + expect(errors).toHaveBeenCalledTimes(1) + expect(indexer.status().lastSweepCompletedAt).toBeNull() + await indexer.reconcile() + expect(resolveRoots).toHaveBeenCalledTimes(2) + expect(indexer.status().lastSweepCompletedAt).not.toBeNull() +}) diff --git a/src/main/ai-vault-search/session-search-scan-roots.test.ts b/src/main/ai-vault-search/session-search-scan-roots.test.ts index 51b7381c78b..39324ca5da8 100644 --- a/src/main/ai-vault-search/session-search-scan-roots.test.ts +++ b/src/main/ai-vault-search/session-search-scan-roots.test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' import { delimiter, join } from 'node:path' import type { SessionFileDiscovery } from '../ai-vault/session-scanner-types' -import { sessionSearchRootListings } from './session-search-scan-roots' +import { sameSessionSearchRoots, sessionSearchRootListings } from './session-search-scan-roots' const STATE = '/tmp/ss-roots/openclaw-state' const LEGACY = '/tmp/ss-roots/openclaw-legacy' @@ -56,3 +56,23 @@ it('attributes a file by path segment, not by string prefix', () => { expect(byRoot[agents]).toBe(0) expect(byRoot[legacy]).toBe(0) }) + +it('reads a re-resolved root set as the same trees when only spelling order differs', () => { + expect( + sameSessionSearchRoots( + { openclawStateDir: STATE, wslHomeDirs: ['/home/a', '/home/b'] }, + { wslHomeDirs: ['/home/b', '/home/a'], openclawStateDir: STATE } + ) + ).toBe(true) + // An absent key and an explicitly undefined one are the same absence. + expect(sameSessionSearchRoots({ openclawStateDir: STATE }, { openclawStateDir: STATE })).toBe( + true + ) +}) + +it('reads an added, dropped or changed root as a different set', () => { + const base = { openclawStateDir: STATE, wslHomeDirs: ['/home/a'] } + expect(sameSessionSearchRoots(base, { ...base, openclawLegacyStateDir: LEGACY })).toBe(false) + expect(sameSessionSearchRoots(base, { openclawStateDir: STATE })).toBe(false) + expect(sameSessionSearchRoots(base, { ...base, wslHomeDirs: ['/home/b'] })).toBe(false) +}) diff --git a/src/main/ai-vault-search/session-search-scan-roots.ts b/src/main/ai-vault-search/session-search-scan-roots.ts index 8df3510199e..5c1041eac25 100644 --- a/src/main/ai-vault-search/session-search-scan-roots.ts +++ b/src/main/ai-vault-search/session-search-scan-roots.ts @@ -133,3 +133,30 @@ export function sessionSearchEmptiedRoots( ): Set { return new Set([...previous].filter((root) => !current.has(root))) } + +/** + * Whether two root sets name the same trees. + * + * Structural, not by reference: the caller re-resolves roots on every policy + * push, so a live index that already walks these trees must not be rebuilt just + * because the object is new. Key-sorted rather than a plain JSON compare because + * nothing fixes the key order two producers write, and list-sorted because the + * indexer walks every root, so a re-enumeration that reorders is not a change. + */ +export function sameSessionSearchRoots( + a: SessionSearchScanRoots, + b: SessionSearchScanRoots +): boolean { + const left = comparableRootFields(a) + const right = comparableRootFields(b) + return left.length === right.length && left.every((field, index) => field === right[index]) +} + +function comparableRootFields(roots: SessionSearchScanRoots): string[] { + return Object.entries(roots) + .filter(([, value]) => value !== undefined) + .map( + ([key, value]) => `${key}=${JSON.stringify(Array.isArray(value) ? [...value].sort() : value)}` + ) + .sort() +} diff --git a/src/main/ai-vault-search/session-search-service-init.ts b/src/main/ai-vault-search/session-search-service-init.ts new file mode 100644 index 00000000000..1ad4e2a2a3a --- /dev/null +++ b/src/main/ai-vault-search/session-search-service-init.ts @@ -0,0 +1,27 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AiVaultSessionSearchInit } from '../ai-vault/session-scanner-service-protocol' +import { sessionSearchDatabasePath } from './session-search-database-path' +import { sessionSearchPolicy } from './session-search-policy' + +// Captured once from the composition root's data path, like the parse cache: +// every export is inert until then, so no test or early import can index. +let databasePath: string | null = null + +export function installSessionSearchDataRoot(dataRoot: string): void { + databasePath = sessionSearchDatabasePath(dataRoot) +} + +/** Read at every spawn and every settings change; null before the data root is installed. */ +export function sessionSearchServiceInit(): AiVaultSessionSearchInit | null { + return databasePath + ? { + databasePath, + settings: sessionSearchPolicy(), + roots: { executionHostId: LOCAL_EXECUTION_HOST_ID } + } + : null +} + +export function resetSessionSearchServiceInitForTests(): void { + databasePath = null +} diff --git a/src/main/ai-vault-search/session-search-service.test.ts b/src/main/ai-vault-search/session-search-service.test.ts index 235b00684cb..8d990bcda5c 100644 --- a/src/main/ai-vault-search/session-search-service.test.ts +++ b/src/main/ai-vault-search/session-search-service.test.ts @@ -86,7 +86,7 @@ describe('real index to public service adapter', () => { hits: [expect.objectContaining({ evidence: null })] }) await service.reconcile() - expect(indexer.reconcile).toHaveBeenCalledExactlyOnceWith({ full: false }) + expect(indexer.reconcile).toHaveBeenCalledExactlyOnceWith({ full: true }) const status = await service.status() expect(AiVaultSearchStatusSchema.parse(status)).toEqual(status) expect(status.generation).toBeGreaterThan(0) diff --git a/src/main/ai-vault-search/session-search-service.ts b/src/main/ai-vault-search/session-search-service.ts index 0f9cf6c615e..237d59eebf7 100644 --- a/src/main/ai-vault-search/session-search-service.ts +++ b/src/main/ai-vault-search/session-search-service.ts @@ -21,7 +21,7 @@ export function createSessionSearchService({ indexer: Pick }): SessionSearchService { return { - reconcile: () => indexer.reconcile({ full: false }), + reconcile: () => indexer.reconcile({ full: true }), status: async () => ({ enabled: true, ...indexer.status(), generation: engine.generation() }), search: async (request) => { if (request.cursor === '') { diff --git a/src/main/ai-vault-search/session-search-sqlite-support.ts b/src/main/ai-vault-search/session-search-sqlite-support.ts new file mode 100644 index 00000000000..c59fe8d3db3 --- /dev/null +++ b/src/main/ai-vault-search/session-search-sqlite-support.ts @@ -0,0 +1,26 @@ +/** + * Whether this Node can hold an index at all. + * + * The store is `node:sqlite`, reached through `process.getBuiltinModule`, which + * neither exists on Node 18. That is not a hypothetical floor: orcad and the SSH + * relay are both built for Node 18 and run on whatever the host has, and + * build-orcad.mjs keeps that floor deliberately by excluding the only clusters + * that import `node:sqlite` statically. A host without it registers no search + * service at all rather than one that fails at every call. + */ +export function sessionSearchSqliteAvailable(): boolean { + if (typeof process.getBuiltinModule !== 'function') { + return false + } + try { + const sqlite: unknown = process.getBuiltinModule('node:sqlite') + return ( + typeof sqlite === 'object' && + sqlite !== null && + 'DatabaseSync' in sqlite && + typeof sqlite.DatabaseSync === 'function' + ) + } catch { + return false + } +} diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index c9feb5b9daf..673de66e666 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -7,6 +7,7 @@ import { import { getCachedWslDistros, hasCachedWslDistros, listRunningWslHomeDirsAsync } from '../wsl' import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import type { AiVaultScanOptions } from './session-scanner-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator' import { @@ -49,6 +50,28 @@ export function configureAiVaultSessionSources(next: AiVaultSessionSources): voi sources = next } +/** + * The trees a local scan enumerates, resolved fresh because a WSL distro can start + * or stop between scans. The search index reads the same function, so it walks + * exactly what the session list walks. + */ +export async function localAiVaultScanRoots(): Promise< + Required> & + Pick +> { + const [additionalCodexHomes, wslHomeDirs] = await Promise.all([ + filterPathsToRunningWslDistrosAsync(configuredAdditionalCodexHomePaths()), + getAiVaultWslHomeDirs() + ]) + return { + additionalCodexSessionsDirs: additionalCodexHomes.map((homePath) => join(homePath, 'sessions')), + wslHomeDirs, + // Why: this scan is always host-local; callers addressing this host by a + // runtime id get the result restamped at the RPC edge, never rescanned. + executionHostId: LOCAL_EXECUTION_HOST_ID + } +} + /** The extra Codex homes session discovery scans. Anything that decides what a listed row may be * resumed from must read the same set, or a row can be listed and then refuse to resume. */ export function configuredAdditionalCodexHomePaths(): readonly string[] { @@ -86,24 +109,12 @@ export async function listAiVaultSessions( force: args?.force, signal: options.signal, start: async (scanSignal) => { - const configuredCodexHomes = sources.getAdditionalCodexHomePaths?.() ?? [] - const [additionalCodexHomes, wslHomeDirs] = await Promise.all([ - filterPathsToRunningWslDistrosAsync(configuredCodexHomes), - getAiVaultWslHomeDirs() - ]) - const additionalCodexSessionsDirs = additionalCodexHomes.map((homePath) => - join(homePath, 'sessions') - ) const result = await scanAiVaultSessionsInBackground( { limit: args?.limit, unlimited: args?.unlimited, scopePaths: args?.scopePaths, - additionalCodexSessionsDirs, - wslHomeDirs, - // Why: this scan is always host-local; callers addressing this host by a - // runtime id get the result restamped at the RPC edge, never rescanned. - executionHostId: LOCAL_EXECUTION_HOST_ID + ...(await localAiVaultScanRoots()) }, scanSignal ) diff --git a/src/main/ai-vault/session-scanner-service-client-state.ts b/src/main/ai-vault/session-scanner-service-client-state.ts index 9b64431e219..cc5b514988b 100644 --- a/src/main/ai-vault/session-scanner-service-client-state.ts +++ b/src/main/ai-vault/session-scanner-service-client-state.ts @@ -1,9 +1,12 @@ +import type { SessionSearchScanRoots } from '../ai-vault-search/session-search-scan-roots' import type { ChildProcess } from 'node:child_process' +import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation' import { AI_VAULT_SERVICE_PROTOCOL_VERSION, type AiVaultServiceInit, type AiVaultServiceLane, - type AiVaultServiceRequest + type AiVaultServiceRequest, + type AiVaultSessionSearchInit } from './session-scanner-service-protocol' export const AI_VAULT_SERVICE_READY_TIMEOUT_MS = 5_000 @@ -16,7 +19,9 @@ export const AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000 export type AiVaultServiceProcessFactory = () => ChildProcess export type AiVaultServiceClientOptions = { processFactory: AiVaultServiceProcessFactory - init: Omit + /** Resolved per spawn: a respawned child must see current consent, not the first frame's. */ + init: () => Omit + resolveSessionSearchRoots?: () => Promise idleTimeoutMs?: number onStderr?: (text: string) => void } @@ -49,6 +54,29 @@ export class AiVaultServiceInvalidations { }) } + /** + * Sends one invalidation and resolves on the child's acknowledgement. + * + * The deadline is a startup-sized budget, but a child mid-scan can be slow to + * turn the channel around. Fork IPC ordering already guarantees the child + * applies the invalidation before any request sent after it, so a busy child + * owes nothing here -- only an idle one that misses the deadline is wedged. + */ + send( + child: ChildProcess, + paths: string[], + lanes: { busy: () => boolean; onFault: (error: Error) => void } + ): Promise { + return this.open( + AI_VAULT_SERVICE_READY_TIMEOUT_MS, + (generation) => + lanes.busy() + ? void this.settle(generation) + : lanes.onFault(new Error('AI Vault service cache invalidation timed out.')), + (generation) => child.send({ type: 'invalidate', generation, paths }) + ) + } + settle(generation: number): boolean { const entry = this.pending.get(generation) if (!entry) { @@ -96,7 +124,7 @@ export function retireAiVaultServiceChild(child: ChildProcess): void { child.unref() } -export function armAiVaultServiceCancellationTimeout( +function armAiVaultServiceCancellationTimeout( call: AiVaultServicePendingCall, onExpired: () => void ): void { @@ -107,14 +135,50 @@ export function armAiVaultServiceCancellationTimeout( call.timer.unref?.() } -/** - * A cold start that faults before the request reached the child self-heals on - * the scheduled respawn. Requeue once; the caller rejects when this returns false. - */ +/** Abandons one call, and waits for the child's acknowledgement only when it owes one. */ +export function cancelAiVaultServiceCall( + call: AiVaultServicePendingCall, + lanes: { + queue: AiVaultServicePendingCall[] + active: Map + child: ChildProcess | null + pump: () => void + onFault: (error: Error) => void + } +): void { + if (call.cancelled) { + return + } + call.cancelled = true + call.reject(createAiVaultScanCancelledError()) + const queuedIndex = lanes.queue.indexOf(call) + if (queuedIndex !== -1) { + lanes.queue.splice(queuedIndex, 1) + clearAiVaultServiceCall(call) + lanes.pump() + return + } + if (lanes.active.get(call.lane) !== call) { + return + } + // Why: a call cancelled before it reached the child gets no acknowledgement, + // so waiting on one would kill a healthy service and stall the lane. + if (!call.sent) { + lanes.active.delete(call.lane) + clearAiVaultServiceCall(call) + lanes.pump() + return + } + lanes.child?.send({ type: 'cancel', id: call.request.id }) + armAiVaultServiceCancellationTimeout(call, () => + lanes.onFault(new Error('AI Vault service did not cancel within 2000ms.')) + ) +} + /** Wires a freshly forked child to the client's callbacks and hands it the init frame. */ export function attachAiVaultServiceChild( child: ChildProcess, - init: AiVaultServiceClientOptions['init'], + init: ReturnType, handlers: { onMessage: (message: unknown) => void onFault: (error: Error) => void @@ -133,16 +197,26 @@ export function attachAiVaultServiceChild( } satisfies AiVaultServiceInit) } -export function requeueAiVaultServiceStart( +/** + * A cold start that faults before the request reached the child self-heals on + * the scheduled respawn. Requeue once; anything else is the caller's error. + */ +export function requeueOrRejectAiVaultServiceStart( call: AiVaultServicePendingCall, - queue: AiVaultServicePendingCall[] -): boolean { - if (call.sent || call.cancelled || call.startRetried) { - return false + queue: AiVaultServicePendingCall[], + error: Error, + respawning: boolean +): void { + if (!respawning || call.sent || call.cancelled || call.startRetried) { + rejectAiVaultServiceCall(call, error) + return } call.startRetried = true queue.unshift(call) - return true +} + +export function aiVaultServiceErrorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) } export function clearAiVaultServiceCall(call: AiVaultServicePendingCall): void { @@ -205,3 +279,52 @@ export class AiVaultServiceIdleRetirement { this.timer.unref?.() } } + +/** + * The parent's half of the index setting. + * + * A child running the index is never idle from out here -- its reconcile loop is + * invisible to the parent -- so this is what stops idle retirement ending the + * indexing until some later scan happens to respawn a child. + */ +export class AiVaultServiceSessionSearchHold { + private enabled = false + + /** True while a running index needs a child to exist. */ + get holdsChild(): boolean { + return this.enabled + } + + /** + * Records the policy and tells a live child. A missing one reads the same + * policy out of its init frame, which is why `init` is a factory, not a value. + * @returns whether a child now has to exist. + */ + record(init: AiVaultSessionSearchInit, child: ChildProcess | null): boolean { + this.enabled = init.settings.enabled + child?.send({ type: 'sessionSearch', init }) + return this.enabled + } +} + +/** Starts the request deadline only once the child is ready to receive it. */ +export function sendAiVaultServiceCall( + child: ChildProcess, + call: AiVaultServicePendingCall, + isActive: () => boolean, + onFault: (error: Error) => void +): void { + if (call.cancelled || !isActive()) { + return + } + const timeoutMs = + call.request.operation === 'scan' + ? AI_VAULT_SERVICE_SCAN_TIMEOUT_MS + : AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS + call.timer = setTimeout(() => { + onFault(new Error(`AI Vault service timed out after ${timeoutMs}ms.`)) + }, timeoutMs) + call.timer.unref?.() + call.sent = true + child.send(call.request) +} diff --git a/src/main/ai-vault/session-scanner-service-client.test.ts b/src/main/ai-vault/session-scanner-service-client.test.ts index 97cb0d46afd..1e8a67602f3 100644 --- a/src/main/ai-vault/session-scanner-service-client.test.ts +++ b/src/main/ai-vault/session-scanner-service-client.test.ts @@ -1,12 +1,36 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AiVaultScannerServiceClient } from './session-scanner-service-client' import { AI_VAULT_SERVICE_READY_TIMEOUT_MS } from './session-scanner-service-client-state' +import type { AiVaultSessionSearchInit } from './session-scanner-service-protocol' import { AiVaultServiceTestChild, aiVaultServiceRequestId, readyAiVaultServiceChild } from './session-scanner-service-test-child' +const SESSION_SEARCH_ON: AiVaultSessionSearchInit = { + databasePath: '/data/ai-vault/session-search.sqlite', + settings: { enabled: true, historyDays: null }, + roots: {} +} + +/** Every fork the client makes, so a respawn can be told from the first start. */ +function setupChildren(policy: () => AiVaultSessionSearchInit | null): { + children: AiVaultServiceTestChild[] + client: AiVaultScannerServiceClient +} { + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(12_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: () => ({ sessionParseCache: null, sessionSearch: policy() }) + }) + return { children, client } +} + function setup(idleTimeoutMs?: number): { child: AiVaultServiceTestChild client: AiVaultScannerServiceClient @@ -14,7 +38,7 @@ function setup(idleTimeoutMs?: number): { const child = new AiVaultServiceTestChild() const client = new AiVaultScannerServiceClient({ processFactory: () => child.asChildProcess(), - init: { sessionParseCache: null }, + init: () => ({ sessionParseCache: null, sessionSearch: null }), idleTimeoutMs }) return { child, client } @@ -135,7 +159,7 @@ describe('AiVaultScannerServiceClient', () => { children.push(child) return child.asChildProcess() }, - init: { sessionParseCache: null } + init: () => ({ sessionParseCache: null, sessionSearch: null }) }) const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) expect(children).toHaveLength(1) @@ -167,7 +191,7 @@ describe('AiVaultScannerServiceClient', () => { children.push(child) return child.asChildProcess() }, - init: { sessionParseCache: null } + init: () => ({ sessionParseCache: null, sessionSearch: null }) }) const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) @@ -190,7 +214,7 @@ describe('AiVaultScannerServiceClient', () => { children.push(child) return child.asChildProcess() }, - init: { sessionParseCache: null } + init: () => ({ sessionParseCache: null, sessionSearch: null }) }) const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) @@ -224,7 +248,7 @@ describe('AiVaultScannerServiceClient', () => { children.push(child) return child.asChildProcess() }, - init: { sessionParseCache: null } + init: () => ({ sessionParseCache: null, sessionSearch: null }) }) // Each request retries its cold start once, so two requests spend the three // faults the circuit breaker needs. @@ -242,11 +266,9 @@ describe('AiVaultScannerServiceClient', () => { vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) await Promise.resolve() vi.advanceTimersByTime(5_000) - await expect(blocked).rejects.toThrow('circuit is open') expect(children).toHaveLength(3) client.clearRestartCircuit() - const retried = client.request({ type: 'request', operation: 'titles', requests: [] }) await vi.waitFor(() => expect(children).toHaveLength(4)) readyAiVaultServiceChild(children[3]!) await vi.waitFor(() => @@ -258,7 +280,7 @@ describe('AiVaultScannerServiceClient', () => { operation: 'titles', value: { titles: [] } }) - await expect(retried).resolves.toEqual({ titles: [] }) + await expect(blocked).resolves.toEqual({ titles: [] }) client.dispose() }) @@ -314,7 +336,7 @@ describe('AiVaultScannerServiceClient', () => { children.push(child) return child.asChildProcess() }, - init: { sessionParseCache: null } + init: () => ({ sessionParseCache: null, sessionSearch: null }) }) const invalidation = client.invalidate(['/tmp/deleted.jsonl']) readyAiVaultServiceChild(children[0]!) @@ -350,7 +372,7 @@ describe('AiVaultScannerServiceClient', () => { children.push(child) return child.asChildProcess() }, - init: { sessionParseCache: null }, + init: () => ({ sessionParseCache: null, sessionSearch: null }), idleTimeoutMs: 100 }) @@ -392,6 +414,152 @@ describe('AiVaultScannerServiceClient', () => { client.dispose() }) + // The child holds the index while the setting is on, and its reconcile loop is + // invisible from here: retiring it would stop indexing until the next scan + // happened to respawn one, which is not a guarantee anyone stated. + it('spawns a child for the index and never retires it while the index is on', async () => { + vi.useFakeTimers() + const { child, client } = setup(100) + const on = { + databasePath: '/data/ai-vault/session-search.sqlite', + settings: { enabled: true, historyDays: null }, + roots: {} + } + + // No request outstanding: turning the index on is itself what spawns a child. + client.updateSessionSearch(on) + readyAiVaultServiceChild(child) + await Promise.resolve() + expect(child.sent).toContainEqual(expect.objectContaining({ type: 'init' })) + + vi.advanceTimersByTime(10_000) + expect(child.sent).not.toContainEqual({ type: 'shutdown' }) + + // A live child hears the change directly rather than waiting for a respawn. + const narrowed = { ...on, settings: { enabled: true, historyDays: 30 } } + client.updateSessionSearch(narrowed) + expect(child.sent).toContainEqual({ type: 'sessionSearch', init: narrowed }) + vi.advanceTimersByTime(10_000) + expect(child.sent).not.toContainEqual({ type: 'shutdown' }) + + client.updateSessionSearch({ ...on, settings: { enabled: false, historyDays: null } }) + vi.advanceTimersByTime(100) + expect(child.sent).toContainEqual({ type: 'shutdown' }) + client.dispose() + }) + + it('re-reads the init frame on every spawn so a respawn sees current consent', async () => { + const children: AiVaultServiceTestChild[] = [] + let enabled = false + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(12_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: () => ({ + sessionParseCache: null, + sessionSearch: { + databasePath: '/data/ai-vault/session-search.sqlite', + settings: { enabled, historyDays: null }, + roots: {} + } + }) + }) + + const first = client.request({ type: 'request', operation: 'titles', requests: [] }) + readyAiVaultServiceChild(children[0]!) + await Promise.resolve() + expect(children[0]!.sent[0]).toMatchObject({ sessionSearch: { settings: { enabled: false } } }) + + enabled = true + children[0]!.emit('error', new Error('crashed')) + await expect(first).rejects.toThrow('crashed') + void client.request({ type: 'request', operation: 'titles', requests: [] }).catch(() => {}) + await vi.waitFor(() => expect(children.length).toBeGreaterThan(1)) + for (const respawned of children.slice(1)) { + expect(respawned.sent[0]).toMatchObject({ sessionSearch: { settings: { enabled: true } } }) + } + client.dispose() + }) + + // The hold is the only thing keeping this child alive, so nothing else will + // restart it: without its own restart, an idle indexing child that crashes + // leaves the index stopped until some unrelated request happens to arrive. + it('restarts a child that faulted while the index was holding it', async () => { + vi.useFakeTimers() + const { children, client } = setupChildren(() => SESSION_SEARCH_ON) + client.updateSessionSearch(SESSION_SEARCH_ON) + readyAiVaultServiceChild(children[0]!) + await Promise.resolve() + + // No queued call and no outstanding invalidation: an idle child simply dies. + children[0]!.emit('error', new Error('crashed')) + expect(children).toHaveLength(1) + vi.advanceTimersByTime(250) + + expect(children).toHaveLength(2) + expect(children[1]!.sent[0]).toMatchObject({ + type: 'init', + sessionSearch: { settings: { enabled: true } } + }) + client.dispose() + }) + + it.each([false, true])( + 'waits for circuit expiry before restarting a held child (dispose=%s)', + async (dispose) => { + vi.useFakeTimers() + const { children, client } = setupChildren(() => SESSION_SEARCH_ON) + try { + client.updateSessionSearch(SESSION_SEARCH_ON) + for (const delay of [250, 1_000]) { + readyAiVaultServiceChild(children.at(-1)!) + await Promise.resolve() + children.at(-1)!.emit('error', new Error('temporary fault')) + await vi.advanceTimersByTimeAsync(delay) + } + expect(children).toHaveLength(3) + readyAiVaultServiceChild(children[2]!) + await Promise.resolve() + children[2]!.emit('error', new Error('temporary fault')) + await vi.advanceTimersByTimeAsync(59_999) + expect(children).toHaveLength(3) + if (dispose) { + client.dispose() + } + await vi.advanceTimersByTimeAsync(1) + expect(children).toHaveLength(dispose ? 3 : 4) + if (!dispose) { + readyAiVaultServiceChild(children[3]!) + } + } finally { + client.dispose() + } + } + ) + + it('leaves a faulted idle child dead while the index is off', async () => { + vi.useFakeTimers() + const { children, client } = setupChildren(() => null) + const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) + readyAiVaultServiceChild(children[0]!) + await Promise.resolve() + children[0]!.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(children[0]!, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + await titles + + children[0]!.emit('error', new Error('crashed')) + vi.advanceTimersByTime(5_000) + + expect(children).toHaveLength(1) + client.dispose() + }) + it('retires an idle child gracefully, then kills it after the shutdown bound', async () => { vi.useFakeTimers() const { child, client } = setup(100) diff --git a/src/main/ai-vault/session-scanner-service-client.ts b/src/main/ai-vault/session-scanner-service-client.ts index e068f2e2e0a..e547ee1dee7 100644 --- a/src/main/ai-vault/session-scanner-service-client.ts +++ b/src/main/ai-vault/session-scanner-service-client.ts @@ -2,19 +2,20 @@ import type { ChildProcess } from 'node:child_process' import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation' import { AI_VAULT_SERVICE_IDLE_TIMEOUT_MS, - AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS, AI_VAULT_SERVICE_MAX_CALLS, AI_VAULT_SERVICE_READY_TIMEOUT_MS, - AI_VAULT_SERVICE_SCAN_TIMEOUT_MS, AiVaultServiceIdleRetirement, AiVaultServiceInvalidations, - armAiVaultServiceCancellationTimeout, + AiVaultServiceSessionSearchHold, + aiVaultServiceErrorText, attachAiVaultServiceChild, + cancelAiVaultServiceCall, clearAiVaultServiceCall, createAiVaultServiceReadyWaiter, rejectAiVaultServiceCall, - requeueAiVaultServiceStart, + requeueOrRejectAiVaultServiceStart, retireAiVaultServiceChild, + sendAiVaultServiceCall, type AiVaultServiceClientOptions, type AiVaultServicePendingCall, type AiVaultServiceReadyWaiter @@ -23,7 +24,7 @@ import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-p import { aiVaultServiceLane, isAiVaultServiceChildMessage, - type AiVaultServiceChildMessage, + type AiVaultSessionSearchInit, type AiVaultServiceRequest, type AiVaultServiceRequestBody, type AiVaultServiceResultValue @@ -38,6 +39,7 @@ export class AiVaultScannerServiceClient { private nextId = 1 private readonly idleRetirement = new AiVaultServiceIdleRetirement() private readonly restartPolicy = new AiVaultServiceRestartPolicy() + private readonly sessionSearch = new AiVaultServiceSessionSearchHold() private disposed = false constructor(private readonly options: AiVaultServiceClientOptions) {} @@ -76,6 +78,19 @@ export class AiVaultScannerServiceClient { }) } + /** Push a consent or retention change, and while the index is on keep a child. */ + updateSessionSearch(init: AiVaultSessionSearchInit): void { + if (this.disposed) { + return + } + if (!this.sessionSearch.record(init, this.child)) { + this.scheduleIdleIfNeeded() + return + } + this.idleRetirement.clear() + this.startSessionSearchChild() + } + clearRestartCircuit(): void { this.restartPolicy.clearCircuit() this.pump() @@ -87,25 +102,10 @@ export class AiVaultScannerServiceClient { } this.idleRetirement.clear() const child = await this.ensureChild() - return this.invalidations.open( - AI_VAULT_SERVICE_READY_TIMEOUT_MS, - (generation) => this.onInvalidationDeadline(generation), - (generation) => child.send({ type: 'invalidate', generation, paths }) - ) - } - - /** - * The deadline is a startup-sized budget, but a child mid-scan can be slow to - * turn the channel around. Fork IPC ordering already guarantees the child - * applies the invalidation before any request sent after it, so a busy child - * owes nothing here — only an idle one that misses the deadline is wedged. - */ - private onInvalidationDeadline(generation: number): void { - if (this.active.size > 0) { - this.invalidations.settle(generation) - return - } - this.onFault(new Error('AI Vault service cache invalidation timed out.')) + return this.invalidations.send(child, paths, { + busy: () => this.active.size > 0, + onFault: (error) => this.onFault(error) + }) } dispose(): void { @@ -140,7 +140,13 @@ export class AiVaultScannerServiceClient { const call = this.queue.splice(index, 1)[0]! this.active.set(lane, call) void this.ensureChild().then( - (child) => this.sendCall(child, call), + (child) => + sendAiVaultServiceCall( + child, + call, + () => this.active.get(call.lane) === call, + (error) => this.onFault(error) + ), (error: Error) => { if (this.active.get(lane) !== call) { return @@ -151,33 +157,32 @@ export class AiVaultScannerServiceClient { } ) } + this.startSessionSearchChild() this.scheduleIdleIfNeeded() } - private sendCall(child: ChildProcess, call: AiVaultServicePendingCall): void { - if (call.cancelled || this.active.get(call.lane) !== call) { + /** + * The index's own restart. A child indexing for the hold has no queued call to + * bring it back, so without this a fault stops the indexing until an unrelated + * request happens to arrive. The restart delay and circuit bound it, exactly as + * they bound a queued call's start. + */ + private startSessionSearchChild(): void { + if (this.disposed || !this.sessionSearch.holdsChild || this.child || this.readyWaiter) { return } - const timeoutMs = - call.request.operation === 'scan' - ? AI_VAULT_SERVICE_SCAN_TIMEOUT_MS - : AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS - call.timer = setTimeout(() => { - this.onFault(new Error(`AI Vault service timed out after ${timeoutMs}ms.`)) - }, timeoutMs) - call.timer.unref?.() - call.sent = true - child.send(call.request) + void this.ensureChild().catch((error: unknown) => { + this.options.onStderr?.(`session search child unavailable: ${aiVaultServiceErrorText(error)}`) + }) } private retryStartOrReject(call: AiVaultServicePendingCall, error: Error): void { - if ( - this.disposed || - !this.restartPolicy.restartScheduled || - !requeueAiVaultServiceStart(call, this.queue) - ) { - rejectAiVaultServiceCall(call, error) - } + requeueOrRejectAiVaultServiceStart( + call, + this.queue, + error, + !this.disposed && this.restartPolicy.restartScheduled + ) } private ensureChild(): Promise { @@ -203,7 +208,7 @@ export class AiVaultScannerServiceClient { this.onFault(new Error('AI Vault service did not become ready.')) ) this.readyWaiter = waiter - attachAiVaultServiceChild(child, this.options.init, { + attachAiVaultServiceChild(child, this.options.init(), { onMessage: (message) => this.onMessage(message), onFault: (error) => this.onFault(error), onStderr: this.options.onStderr @@ -211,12 +216,24 @@ export class AiVaultScannerServiceClient { return waiter.promise } - private onMessage(raw: unknown): void { - if (!isAiVaultServiceChildMessage(raw)) { + private onMessage(message: unknown): void { + if (!isAiVaultServiceChildMessage(message)) { this.onFault(new Error('AI Vault service sent a malformed message.')) return } - const message = raw as AiVaultServiceChildMessage + if (message.type === 'sessionSearchRoots') { + const child = this.child + const resolve = this.options.resolveSessionSearchRoots + void Promise.resolve() + .then(() => (resolve ? resolve() : (this.options.init().sessionSearch?.roots ?? null))) + .catch(() => null) + .then((roots) => { + if (child && this.child === child && child.connected) { + child.send({ type: 'sessionSearchRoots', id: message.id, roots }, () => undefined) + } + }) + return + } if (message.type === 'ready') { const waiter = this.readyWaiter if (!waiter || !this.child) { @@ -250,32 +267,13 @@ export class AiVaultScannerServiceClient { } private cancel(call: AiVaultServicePendingCall): void { - if (call.cancelled) { - return - } - call.cancelled = true - call.reject(createAiVaultScanCancelledError()) - const queuedIndex = this.queue.indexOf(call) - if (queuedIndex !== -1) { - this.queue.splice(queuedIndex, 1) - clearAiVaultServiceCall(call) - this.pump() - return - } - if (this.active.get(call.lane) === call) { - // Why: a call cancelled before it reached the child gets no acknowledgement, - // so waiting on one would kill a healthy service and stall the lane. - if (!call.sent) { - this.active.delete(call.lane) - clearAiVaultServiceCall(call) - this.pump() - return - } - this.child?.send({ type: 'cancel', id: call.request.id }) - armAiVaultServiceCancellationTimeout(call, () => - this.onFault(new Error('AI Vault service did not cancel within 2000ms.')) - ) - } + cancelAiVaultServiceCall(call, { + queue: this.queue, + active: this.active, + child: this.child, + pump: () => this.pump(), + onFault: (error) => this.onFault(error) + }) } private onFault(error: Error): void { @@ -304,7 +302,11 @@ export class AiVaultScannerServiceClient { private scheduleIdleIfNeeded(): void { this.idleRetirement.schedule( - this.active.size > 0 || this.queue.length > 0 || this.invalidations.size > 0 || !this.child, + this.sessionSearch.holdsChild || + this.active.size > 0 || + this.queue.length > 0 || + this.invalidations.size > 0 || + !this.child, this.options.idleTimeoutMs ?? AI_VAULT_SERVICE_IDLE_TIMEOUT_MS, () => this.retireChild() ) diff --git a/src/main/ai-vault/session-scanner-service-entry.ts b/src/main/ai-vault/session-scanner-service-entry.ts index 74a5ea9c355..7458db74e3a 100644 --- a/src/main/ai-vault/session-scanner-service-entry.ts +++ b/src/main/ai-vault/session-scanner-service-entry.ts @@ -1,3 +1,4 @@ +import { requestSessionSearchRoots } from './session-scanner-service-root-request' import type { AiVaultSessionTitle } from '../../shared/ai-vault-session-title' import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read' import { @@ -6,6 +7,7 @@ import { } from './session-parse-cache-persistence' import { scanAiVaultSessions } from './session-scanner' import { invalidateSessionParseCacheEntry } from './session-scanner-parse-cache' +import { SessionScannerServiceSearch } from './session-scanner-service-search' import { AI_VAULT_SERVICE_PROTOCOL_VERSION, aiVaultServiceLane, @@ -29,6 +31,7 @@ const cancelled = new Set() const pending = new Set() const titleIndex = new Map() const invalidatedPaths = new Set() +const sessionSearch = new SessionScannerServiceSearch(requestSessionSearchRoots) let initialized = false let shuttingDown = false let cacheLane = Promise.resolve() @@ -43,6 +46,15 @@ function titleKey(request: { agent: string; sessionId: string }): string { } async function executeRequest(request: AiVaultServiceRequest): Promise { + if (sessionSearch.handles(request)) { + try { + return await sessionSearch.execute(request) + } finally { + // A search registers no controller, so nothing else consumes a cancel sent + // for one; without this the id sits in the set for the process's life. + cancelled.delete(request.id) + } + } const controller = new AbortController() controllers.set(request.id, controller) try { @@ -149,6 +161,7 @@ async function shutdown(): Promise { for (const controller of controllers.values()) { controller.abort() } + sessionSearch.close() await Promise.allSettled([cacheLane, interactiveLane]) await flushSessionParseCachePersist() process.disconnect?.() @@ -164,6 +177,9 @@ process.on('message', (raw: AiVaultServiceParentMessage) => { if (raw.sessionParseCache) { initSessionParseCachePersistence(raw.sessionParseCache) } + if (raw.sessionSearch) { + sessionSearch.apply(raw.sessionSearch) + } send({ type: 'ready', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, pid: process.pid }) return } @@ -192,6 +208,10 @@ process.on('message', (raw: AiVaultServiceParentMessage) => { send({ type: 'invalidated', generation: raw.generation }) return } + if (raw?.type === 'sessionSearch') { + sessionSearch.apply(raw.init) + return + } if (raw?.type === 'shutdown') { void shutdown() return diff --git a/src/main/ai-vault/session-scanner-service-protocol.ts b/src/main/ai-vault/session-scanner-service-protocol.ts index f2842751eb1..df89600934c 100644 --- a/src/main/ai-vault/session-scanner-service-protocol.ts +++ b/src/main/ai-vault/session-scanner-service-protocol.ts @@ -4,6 +4,13 @@ import type { AiVaultSessionTitleRequest, AiVaultSessionTitlesResult } from '../../shared/ai-vault-session-title' +import type { + AiVaultSearchRequest, + AiVaultSearchResponse, + AiVaultSearchStatus +} from '../../shared/ai-vault-search-types' +import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' +import type { SessionSearchScanRoots } from '../ai-vault-search/session-search-scan-roots' import type { ReadAiVaultFirstUserPromptArgs } from './session-first-user-prompt-read' import type { SessionParseCachePersistenceOptions } from './session-parse-cache-persistence' import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' @@ -11,17 +18,49 @@ import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol export const AI_VAULT_SERVICE_PROTOCOL_VERSION = 1 export type AiVaultServiceLane = 'cache' | 'interactive' -export type AiVaultServiceOperation = 'scan' | 'titles' | 'subagents' | 'firstPrompt' +export type AiVaultServiceOperation = + | 'scan' + | 'titles' + | 'subagents' + | 'firstPrompt' + | 'searchSessions' + | 'searchStatus' + | 'searchReconcile' + +// Typed from the union so a new operation cannot be added without landing here, +// and held as strings so recognising one costs no assertion. +const AI_VAULT_SERVICE_OPERATIONS: ReadonlySet = new Set([ + 'scan', + 'titles', + 'subagents', + 'firstPrompt', + 'searchSessions', + 'searchStatus', + 'searchReconcile' +]) export type AiVaultServiceSubagentRequest = { agent: 'claude' | 'omp' parentFilePath: string } +/** + * Everything the child needs to own this host's index. + * + * Initial roots also support standalone tests. Production asks the parent for + * a fresh snapshot on each full sweep; the parent owns managed account homes. + */ +export type AiVaultSessionSearchInit = { + databasePath: string + settings: AiVaultSearchSettings + roots: SessionSearchScanRoots +} + export type AiVaultServiceInit = { type: 'init' protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION sessionParseCache: SessionParseCachePersistenceOptions | null + sessionSearch: AiVaultSessionSearchInit | null } export type AiVaultServiceRequestBody = @@ -41,6 +80,9 @@ export type AiVaultServiceRequestBody = operation: 'firstPrompt' request: ReadAiVaultFirstUserPromptArgs } + | { type: 'request'; operation: 'searchSessions'; request: AiVaultSearchRequest } + | { type: 'request'; operation: 'searchStatus' } + | { type: 'request'; operation: 'searchReconcile' } export type AiVaultServiceRequest = AiVaultServiceRequestBody & { id: number } @@ -49,6 +91,9 @@ export type AiVaultServiceParentMessage = | AiVaultServiceRequest | { type: 'cancel'; id: number } | { type: 'invalidate'; generation: number; paths: string[] } + // Fire-and-forget: the child closes the live pair and constructs from this. + | { type: 'sessionSearch'; init: AiVaultSessionSearchInit } + | { type: 'sessionSearchRoots'; id: number; roots: SessionSearchScanRoots | null } | { type: 'shutdown' } export type AiVaultServiceResultValue = @@ -56,8 +101,12 @@ export type AiVaultServiceResultValue = | { operation: 'titles'; value: AiVaultSessionTitlesResult } | { operation: 'subagents'; value: AiVaultSubagentListResult } | { operation: 'firstPrompt'; value: { prompt: string | null } } + | { operation: 'searchSessions'; value: AiVaultSearchResponse } + | { operation: 'searchStatus'; value: AiVaultSearchStatus } + | { operation: 'searchReconcile'; value: null } export type AiVaultServiceChildMessage = + | { type: 'sessionSearchRoots'; id: number } | { type: 'ready' protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION @@ -67,22 +116,23 @@ export type AiVaultServiceChildMessage = | { type: 'error'; id: number; message: string; retryable: boolean } | { type: 'invalidated'; generation: number } +/** Everything but the two bulk reads is interactive: a search must not queue behind a scan. */ export function aiVaultServiceLane(operation: AiVaultServiceOperation): AiVaultServiceLane { - return operation === 'subagents' || operation === 'firstPrompt' ? 'interactive' : 'cache' + return operation === 'scan' || operation === 'titles' ? 'cache' : 'interactive' } export function isAiVaultServiceRequest(value: unknown): value is AiVaultServiceRequest { if (!value || typeof value !== 'object') { return false } - const message = value as Record return ( - message.type === 'request' && - Number.isSafeInteger(message.id) && - (message.operation === 'scan' || - message.operation === 'titles' || - message.operation === 'subagents' || - message.operation === 'firstPrompt') + 'type' in value && + value.type === 'request' && + 'id' in value && + Number.isSafeInteger(value.id) && + 'operation' in value && + typeof value.operation === 'string' && + AI_VAULT_SERVICE_OPERATIONS.has(value.operation) ) } @@ -94,6 +144,9 @@ export function isAiVaultServiceChildMessage(value: unknown): value is AiVaultSe if (message.type === 'ready') { return message.protocol === AI_VAULT_SERVICE_PROTOCOL_VERSION && Number.isInteger(message.pid) } + if (message.type === 'sessionSearchRoots') { + return Number.isSafeInteger(message.id) + } if (message.type === 'invalidated') { return Number.isSafeInteger(message.generation) } diff --git a/src/main/ai-vault/session-scanner-service-restart-policy.ts b/src/main/ai-vault/session-scanner-service-restart-policy.ts index ae9f437ab3e..02f972691ed 100644 --- a/src/main/ai-vault/session-scanner-service-restart-policy.ts +++ b/src/main/ai-vault/session-scanner-service-restart-policy.ts @@ -43,10 +43,13 @@ export class AiVaultServiceRestartPolicy { if (this.timer) { clearTimeout(this.timer) } - this.timer = setTimeout(() => { - this.timer = null - restart() - }, delay) + this.timer = setTimeout( + () => { + this.timer = null + restart() + }, + Math.max(delay, this.circuitUntil - now) + ) this.timer.unref?.() } diff --git a/src/main/ai-vault/session-scanner-service-root-request.test.ts b/src/main/ai-vault/session-scanner-service-root-request.test.ts new file mode 100644 index 00000000000..0f07b415b4f --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-root-request.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { requestSessionSearchRoots } from './session-scanner-service-root-request' +import { + isAiVaultServiceChildMessage, + type AiVaultServiceChildMessage +} from './session-scanner-service-protocol' + +let originalSend: typeof process.send +let lastRequest: Extract +let listeners: number +beforeEach(() => { + originalSend = process.send + listeners = process.listenerCount('message') + process.send = (message) => { + if (!isAiVaultServiceChildMessage(message) || message.type !== 'sessionSearchRoots') { + throw new Error('Unexpected child message') + } + lastRequest = message + return true + } +}) +afterEach(() => { + process.send = originalSend + expect(process.listenerCount('message')).toBe(listeners) +}) +it('matches the requested snapshot and removes its listener', async () => { + const pending = requestSessionSearchRoots(new AbortController().signal) + process.emit( + 'message', + { type: 'sessionSearchRoots', id: lastRequest.id + 1, roots: {} }, + undefined + ) + const roots = { additionalCodexSessionsDirs: ['/late'] } + process.emit('message', { type: 'sessionSearchRoots', id: lastRequest.id, roots }, undefined) + await expect(pending).resolves.toEqual(roots) +}) +it('releases a pending request when indexing is disabled', async () => { + const controller = new AbortController() + const pending = requestSessionSearchRoots(controller.signal) + controller.abort(new Error('disabled')) + await expect(pending).rejects.toThrow('disabled') +}) +it('reports discovery and send failures instead of using stale roots', async () => { + const pending = requestSessionSearchRoots(new AbortController().signal) + process.emit( + 'message', + { type: 'sessionSearchRoots', id: lastRequest.id, roots: null }, + undefined + ) + await expect(pending).rejects.toThrow('discovery failed') + process.send = () => { + throw new Error('channel closed') + } + await expect(requestSessionSearchRoots(new AbortController().signal)).rejects.toThrow( + 'channel closed' + ) +}) diff --git a/src/main/ai-vault/session-scanner-service-root-request.ts b/src/main/ai-vault/session-scanner-service-root-request.ts new file mode 100644 index 00000000000..4d510dd2b73 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-root-request.ts @@ -0,0 +1,40 @@ +import type { SessionSearchScanRoots } from '../ai-vault-search/session-search-scan-roots' +import type { AiVaultServiceParentMessage } from './session-scanner-service-protocol' + +let nextId = 1 + +/** The parent owns managed-account discovery; the child owns the sweep's lifetime. */ +export async function requestSessionSearchRoots( + signal: AbortSignal +): Promise { + signal.throwIfAborted() + const id = nextId++ + const pending = Promise.withResolvers() + const onAbort = (): void => pending.reject(signal.reason) + const onMessage = (message: AiVaultServiceParentMessage): void => { + if (message?.type !== 'sessionSearchRoots' || message.id !== id) { + return + } + if (message.roots) { + pending.resolve(message.roots) + } else { + pending.reject(new Error('Session search root discovery failed.')) + } + } + process.on('message', onMessage) + signal.addEventListener('abort', onAbort, { once: true }) + try { + if (!process.send) { + throw new Error('Session search root discovery requires parent IPC.') + } + process.send({ type: 'sessionSearchRoots', id }, (error) => { + if (error) { + pending.reject(error) + } + }) + return await pending.promise + } finally { + process.removeListener('message', onMessage) + signal.removeEventListener('abort', onAbort) + } +} diff --git a/src/main/ai-vault/session-scanner-service-root-response.test.ts b/src/main/ai-vault/session-scanner-service-root-response.test.ts new file mode 100644 index 00000000000..6d1d81fd351 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-root-response.test.ts @@ -0,0 +1,63 @@ +import { expect, it, vi } from 'vitest' +import { AiVaultScannerServiceClient } from './session-scanner-service-client' +import { + AiVaultServiceTestChild, + readyAiVaultServiceChild +} from './session-scanner-service-test-child' + +it('answers root requests freshly without forwarding another settings change', async () => { + const child = new AiVaultServiceTestChild() + Object.assign(child, { connected: true }) + const roots = { additionalCodexSessionsDirs: ['/late'] } + const resolveSessionSearchRoots = vi + .fn() + .mockResolvedValueOnce(roots) + .mockRejectedValueOnce(new Error('offline')) + const client = new AiVaultScannerServiceClient({ + processFactory: () => child.asChildProcess(), + init: () => ({ sessionSearch: null, sessionParseCache: null }), + resolveSessionSearchRoots + }) + const status = client.request({ type: 'request', operation: 'searchStatus' }) + try { + readyAiVaultServiceChild(child) + await Promise.resolve() + child.emit('message', { type: 'result', operation: 'searchStatus', id: 1, value: {} }) + await status + child.emit('message', { type: 'sessionSearchRoots', id: 5 }) + await vi.waitFor(() => + expect(child.sent).toContainEqual({ type: 'sessionSearchRoots', id: 5, roots }) + ) + child.emit('message', { type: 'sessionSearchRoots', id: 6 }) + await vi.waitFor(() => + expect(child.sent).toContainEqual({ type: 'sessionSearchRoots', id: 6, roots: null }) + ) + expect(resolveSessionSearchRoots).toHaveBeenCalledTimes(2) + expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'sessionSearch' })) + } finally { + client.dispose() + } +}) + +it('does not deliver a delayed snapshot after the child is disposed', async () => { + const child = new AiVaultServiceTestChild() + Object.assign(child, { connected: true }) + const pending = Promise.withResolvers<{}>() + const resolveSessionSearchRoots = vi.fn(() => pending.promise) + const client = new AiVaultScannerServiceClient({ + processFactory: () => child.asChildProcess(), + init: () => ({ sessionSearch: null, sessionParseCache: null }), + resolveSessionSearchRoots + }) + const status = client.request({ type: 'request', operation: 'searchStatus' }) + readyAiVaultServiceChild(child) + await Promise.resolve() + child.emit('message', { type: 'result', operation: 'searchStatus', id: 1, value: {} }) + await status + child.emit('message', { type: 'sessionSearchRoots', id: 5 }) + await vi.waitFor(() => expect(resolveSessionSearchRoots).toHaveBeenCalledTimes(1)) + client.dispose() + pending.resolve({}) + await new Promise((resolve) => setImmediate(resolve)) + expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'sessionSearchRoots' })) +}) diff --git a/src/main/ai-vault/session-scanner-service-search-roots.test.ts b/src/main/ai-vault/session-scanner-service-search-roots.test.ts new file mode 100644 index 00000000000..5a206646ea0 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-search-roots.test.ts @@ -0,0 +1,106 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + openSessionSearchIndexerHarness, + writeMessageGraphTranscript, + type SessionSearchIndexerHarness +} from '../ai-vault-search/session-search-indexer-test-fixture' +import { SessionSearchIndexer } from '../ai-vault-search/session-search-indexer' +import type { SessionSearchScanRoots } from '../ai-vault-search/session-search-scan-roots' +import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' +import type { AiVaultSessionSearchInit } from './session-scanner-service-protocol' +import { SessionScannerServiceSearch } from './session-scanner-service-search' +import { resetTranscriptConsumersForTests } from './session-transcript-consumers' + +let harness: SessionSearchIndexerHarness +let subject: SessionScannerServiceSearch +let spawnRoot: string +let lateRoot: string +let currentRoots: SessionSearchScanRoots +let spawnRoots: SessionSearchScanRoots + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + harness = await openSessionSearchIndexerHarness('ss-service-roots') + subject = new SessionScannerServiceSearch(async () => currentRoots) + const { openclawLegacyStateDir, ...rest } = harness.roots + spawnRoot = harness.roots.openclawStateDir ?? '' + lateRoot = openclawLegacyStateDir ?? '' + spawnRoots = rest + currentRoots = rest +}) + +afterEach(async () => { + subject.close() + vi.restoreAllMocks() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +function init(roots: SessionSearchScanRoots): AiVaultSessionSearchInit { + return { + databasePath: harness.databasePath, + settings: { enabled: true, historyDays: null }, + roots + } +} + +/** OpenClaw reads `/agents/**` and keeps only paths through `sessions`. */ +function openclawTranscript(stateDir: string, name: string): string { + return join(stateDir, 'agents', 'main', 'sessions', `${name}.jsonl`) +} + +async function sessionsMatching(term: string): Promise { + const reply = await subject.execute({ + type: 'request', + id: 1, + operation: 'searchSessions', + request: { query: term } + }) + if (reply.operation !== 'searchSessions' || reply.value.kind !== 'results') { + throw new Error(`expected results, got ${JSON.stringify(reply)}`) + } + return reply.value.hits.map((hit) => hit.sessionId).sort() +} + +async function indexedSessions(term: string, expected: string[]): Promise { + await vi.waitFor( + async () => { + await subject.execute({ type: 'request', id: 2, operation: 'searchReconcile' }) + expect(await sessionsMatching(term)).toEqual(expected) + }, + { timeout: 20_000 } + ) +} + +it('refreshes a late root without rebuilding the index', async () => { + await writeMessageGraphTranscript(openclawTranscript(spawnRoot, 'early-session'), [ + 'a conversation in a root the spawn already knew' + ]) + await writeMessageGraphTranscript(openclawTranscript(lateRoot, 'late-session'), [ + 'a conversation in a distro that started later' + ]) + + subject.apply(init(spawnRoots)) + await indexedSessions('conversation', ['early-session']) + + const close = vi.spyOn(SessionSearchIndexer.prototype, 'close') + currentRoots = harness.roots + await indexedSessions('conversation', ['early-session', 'late-session']) + expect(close).not.toHaveBeenCalled() +}) + +it('keeps the live indexer when an unchanged root snapshot is refreshed', async () => { + await writeMessageGraphTranscript(openclawTranscript(spawnRoot, 'early-session'), [ + 'a conversation in a root the spawn already knew' + ]) + subject.apply(init(harness.roots)) + await indexedSessions('conversation', ['early-session']) + + const close = vi.spyOn(SessionSearchIndexer.prototype, 'close') + currentRoots = { ...spawnRoots } + await indexedSessions('conversation', ['early-session']) + expect(close).not.toHaveBeenCalled() +}) diff --git a/src/main/ai-vault/session-scanner-service-search.test.ts b/src/main/ai-vault/session-scanner-service-search.test.ts new file mode 100644 index 00000000000..d0c3394883d --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-search.test.ts @@ -0,0 +1,166 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, expect, it, vi } from 'vitest' +import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types' +import { + openSessionSearchIndexerHarness, + writeClaudeTranscript, + type SessionSearchIndexerHarness +} from '../ai-vault-search/session-search-indexer-test-fixture' +import { + AI_VAULT_SERVICE_PROTOCOL_VERSION, + type AiVaultServiceChildMessage, + type AiVaultServiceParentMessage, + type AiVaultServiceRequestBody, + type AiVaultServiceResultValue, + type AiVaultSessionSearchInit +} from './session-scanner-service-protocol' + +/** + * The child, booted the way a spawn boots it: an init frame and messages, with + * no renderer, no Electron and no scan request. What this proves is that consent + * alone constructs the indexer and that every search answer crosses the protocol. + */ + +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +let harness: SessionSearchIndexerHarness +let currentRoots: SessionSearchIndexerHarness['roots'] +let originalSend: typeof process.send +const sent: AiVaultServiceChildMessage[] = [] +let nextId = 1 + +function emit(message: AiVaultServiceParentMessage): void { + process.emit('message', message, undefined) +} + +/** One request, and the reply the child sent for it, still discriminated by operation. */ +async function call(body: AiVaultServiceRequestBody): Promise { + const id = nextId++ + emit({ ...body, id }) + const reply = await vi.waitFor(() => { + const found = sent.find( + (message) => (message.type === 'result' || message.type === 'error') && message.id === id + ) + expect(found).toBeDefined() + return found! + }) + if (reply.type === 'error') { + throw new Error(reply.message) + } + if (reply.type !== 'result') { + throw new Error(`expected a result, got ${reply.type}`) + } + return reply +} + +async function searchStatus(): Promise { + const reply = await call({ type: 'request', operation: 'searchStatus' }) + if (reply.operation !== 'searchStatus') { + throw new Error(`expected searchStatus, got ${reply.operation}`) + } + return reply.value +} + +async function searchSessions(query: string): Promise { + const reply = await call({ type: 'request', operation: 'searchSessions', request: { query } }) + if (reply.operation !== 'searchSessions') { + throw new Error(`expected searchSessions, got ${reply.operation}`) + } + return reply.value +} + +function searchInit(enabled: boolean): AiVaultSessionSearchInit { + return { + databasePath: harness.databasePath, + settings: { enabled, historyDays: null }, + roots: harness.roots + } +} + +beforeAll(async () => { + harness = await openSessionSearchIndexerHarness('ss-child') + currentRoots = harness.roots + await writeClaudeTranscript( + join(harness.claudeProjectDir, `${SESSION_ID}.jsonl`), + ['a distinctive conversation'], + SESSION_ID + ) + originalSend = process.send + const record: NonNullable = (message) => { + sent.push(message) + if (message.type === 'sessionSearchRoots') { + queueMicrotask(() => + emit({ type: 'sessionSearchRoots', id: message.id, roots: currentRoots }) + ) + } + return true + } + process.send = record + await import('./session-scanner-service-entry') + emit({ + type: 'init', + protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, + sessionParseCache: null, + sessionSearch: searchInit(true) + }) + await vi.waitFor(() => expect(sent.some((message) => message.type === 'ready')).toBe(true)) +}) + +afterAll(async () => { + emit({ type: 'sessionSearch', init: searchInit(false) }) + process.send = originalSend + await harness.cleanup() +}) + +it('reports the indexer phase and a live generation over the protocol', async () => { + const status = await vi.waitFor(async () => { + const value = await searchStatus() + expect(value.filesIndexed).toBeGreaterThan(0) + return value + }) + expect(status.enabled).toBe(true) + expect(status.phase).toBe('current') + expect(status.generation).toBeGreaterThan(0) + expect(existsSync(harness.databasePath)).toBe(true) +}) + +it('answers a search and a reconcile over the protocol', async () => { + expect(await call({ type: 'request', operation: 'searchReconcile' })).toEqual({ + operation: 'searchReconcile', + value: null, + type: 'result', + id: expect.any(Number) + }) + const response = await searchSessions('distinctive') + expect(response.kind).toBe('results') + if (response.kind === 'results') { + expect(response.hits.map((hit) => hit.sessionId)).toEqual([SESSION_ID]) + } +}) + +it('discovers a new root through the parent exchange on manual reconciliation', async () => { + const lateHome = join(harness.root, 'late-home') + const id = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + await writeClaudeTranscript( + join(lateHome, '.claude', 'projects', 'late', `${id}.jsonl`), + ['freshroots'], + id + ) + currentRoots = { ...harness.roots, wslHomeDirs: [lateHome] } + await call({ type: 'request', operation: 'searchReconcile' }) + const response = await searchSessions('freshroots') + expect(response.kind).toBe('results') + if (response.kind === 'results') { + expect(response.hits.map((hit) => hit.sessionId)).toEqual([id]) + } +}) + +it('answers disabled once consent is withdrawn, without a respawn', async () => { + emit({ type: 'sessionSearch', init: searchInit(false) }) + expect(await searchSessions('distinctive')).toEqual({ kind: 'unavailable', reason: 'disabled' }) + expect(await searchStatus()).toMatchObject({ enabled: false, phase: 'idle' }) + // Re-consenting reuses the index that was left on disk rather than rebuilding it. + emit({ type: 'sessionSearch', init: searchInit(true) }) + expect((await searchSessions('distinctive')).kind).toBe('results') +}) diff --git a/src/main/ai-vault/session-scanner-service-search.ts b/src/main/ai-vault/session-scanner-service-search.ts new file mode 100644 index 00000000000..deab1c26beb --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-search.ts @@ -0,0 +1,94 @@ +import type { SessionSearchIndexerOptions } from '../ai-vault-search/session-search-indexer-options' +import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' +import { AiVaultSearchRequestSchema } from '../../shared/ai-vault-search-contract' +import { SessionSearchInstance } from '../ai-vault-search/session-search-instance' +import { + sameSessionSearchRoots, + type SessionSearchScanRoots +} from '../ai-vault-search/session-search-scan-roots' +import { sessionSearchSqliteAvailable } from '../ai-vault-search/session-search-sqlite-support' +import type { + AiVaultServiceRequest, + AiVaultServiceResultValue, + AiVaultSessionSearchInit +} from './session-scanner-service-protocol' + +type SearchOperation = Extract< + AiVaultServiceRequest, + { operation: 'searchSessions' | 'searchStatus' | 'searchReconcile' } +> + +/** + * The scanner-service child's half of session search. + * + * Why the child and not the parent: the transcript reader runs here, so the + * index consumer has to as well — one process reads a transcript once and both + * the session list and the index see that read. Main, the CLI and a remote + * server never open the database; they ask over this protocol. + */ +export class SessionScannerServiceSearch { + private instance: SessionSearchInstance | null = null + private databasePath: string | null = null + private roots: SessionSearchScanRoots | null = null + + constructor(private readonly resolveRoots?: SessionSearchIndexerOptions['resolveRoots']) {} + + /** Applied at init and again on every settings change; both are close-and-construct. */ + apply(init: AiVaultSessionSearchInit): void { + if (!sessionSearchSqliteAvailable()) { + return + } + if (this.instance && this.databasePath !== init.databasePath) { + // A data root cannot move under a running process, so this is a caller bug + // rather than a case to support: close the old one before it writes there. + this.close() + } + if (this.instance && this.roots && !sameSessionSearchRoots(this.roots, init.roots)) { + // Explicit init-root changes replace the fallback used by callers without a resolver. + this.close() + } + this.databasePath = init.databasePath + this.roots = init.roots + this.instance ??= new SessionSearchInstance({ + databasePath: init.databasePath, + roots: init.roots, + resolveRoots: this.resolveRoots + }) + this.instance.apply(init.settings) + } + + handles(request: AiVaultServiceRequest): request is SearchOperation { + return ( + request.operation === 'searchSessions' || + request.operation === 'searchStatus' || + request.operation === 'searchReconcile' + ) + } + + async execute(request: SearchOperation): Promise { + const instance = this.instance + if (request.operation === 'searchStatus') { + return { + operation: 'searchStatus', + value: instance?.status() ?? unavailableSessionSearchStatus() + } + } + if (request.operation === 'searchReconcile') { + await instance?.reconcile() + return { operation: 'searchReconcile', value: null } + } + return { + operation: 'searchSessions', + value: instance + ? await instance.search(AiVaultSearchRequestSchema.parse(request.request)) + : { kind: 'unavailable', reason: 'disabled' } + } + } + + close(): void { + this.instance?.close() + this.instance = null + this.databasePath = null + this.roots = null + } +} diff --git a/src/main/ai-vault/session-scanner-service-spawn.ts b/src/main/ai-vault/session-scanner-service-spawn.ts index 3ba12322734..d841fc62593 100644 --- a/src/main/ai-vault/session-scanner-service-spawn.ts +++ b/src/main/ai-vault/session-scanner-service-spawn.ts @@ -1,5 +1,11 @@ +import { localAiVaultScanRoots } from './cached-session-list' import { fork, type ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' +import type { + AiVaultSearchRequest, + AiVaultSearchResponse, + AiVaultSearchStatus +} from '../../shared/ai-vault-search-types' import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types' import type { AiVaultSessionTitleRequest, @@ -10,12 +16,16 @@ import type { ReadAiVaultFirstUserPromptArgs, ReadAiVaultFirstUserPromptResult } from './session-first-user-prompt-read' +import { sessionSearchServiceInit } from '../ai-vault-search/session-search-service-init' import { getSessionParseCachePersistenceOptions } from './session-parse-cache-persistence' import { buildAiVaultServiceEnv } from './session-scanner-service-env' import { AiVaultScannerServiceClient } from './session-scanner-service-client' import { getAiVaultServiceEntryPath } from './session-scanner-service-entry-path' import { lowerAiVaultServicePriority } from './session-scanner-service-priority' -import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol' +import type { + AiVaultServiceSubagentRequest, + AiVaultSessionSearchInit +} from './session-scanner-service-protocol' import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' export function spawnAiVaultServiceProcess(): ChildProcess { @@ -39,7 +49,11 @@ let sharedClient: AiVaultScannerServiceClient | null = null function getSharedClient(): AiVaultScannerServiceClient { sharedClient ??= new AiVaultScannerServiceClient({ processFactory: spawnAiVaultServiceProcess, - init: { sessionParseCache: getSessionParseCachePersistenceOptions() }, + resolveSessionSearchRoots: localAiVaultScanRoots, + init: () => ({ + sessionParseCache: getSessionParseCachePersistenceOptions(), + sessionSearch: sessionSearchServiceInit() + }), onStderr: (text) => console.error('[ai-vault-service]', text.trimEnd()) }) return sharedClient @@ -81,6 +95,25 @@ export function readAiVaultFirstUserPromptInService( return getSharedClient().request({ type: 'request', operation: 'firstPrompt', request }, signal) } +export function searchSessionsInService( + request: AiVaultSearchRequest +): Promise { + return getSharedClient().request({ type: 'request', operation: 'searchSessions', request }) +} + +export function sessionSearchStatusInService(): Promise { + return getSharedClient().request({ type: 'request', operation: 'searchStatus' }) +} + +export function reconcileSessionSearchInService(): Promise { + return getSharedClient().request({ type: 'request', operation: 'searchReconcile' }) +} + +/** Boot and every settings change: push the policy and keep a child while the index runs. */ +export function updateSessionSearchInService(init: AiVaultSessionSearchInit): void { + getSharedClient().updateSessionSearch(init) +} + export function invalidateAiVaultServiceCache(paths: string[]): Promise { return sharedClient?.invalidate(paths) ?? Promise.resolve() } diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index ac9e6a9bb61..870eac98d46 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -138,7 +138,8 @@ const { vi.mock('electron', () => ({ app: { - getPath: getPathMock + getPath: getPathMock, + once: vi.fn() } })) diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index a6a90f2b9bf..31d587bd056 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -13,6 +13,7 @@ const { resolveEnvironmentMock, rebuildAppMenuMock, applyBrowserSessionProxiesMock, + applySessionSearchSettingsChangeMock, listProfilesMock } = vi.hoisted(() => ({ applyAppIconMock: vi.fn(), @@ -27,6 +28,7 @@ const { resolveEnvironmentMock: vi.fn(), rebuildAppMenuMock: vi.fn(), applyBrowserSessionProxiesMock: vi.fn(), + applySessionSearchSettingsChangeMock: vi.fn(), listProfilesMock: vi.fn(() => []) })) @@ -61,6 +63,10 @@ vi.mock('../app-icon', () => ({ applyAppIcon: applyAppIconMock })) +vi.mock('../ai-vault-search/session-search-enablement', () => ({ + applySessionSearchSettingsChange: applySessionSearchSettingsChangeMock +})) + vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock })) @@ -113,6 +119,7 @@ describe('registerSettingsHandlers', () => { }) rebuildAppMenuMock.mockClear() applyBrowserSessionProxiesMock.mockReset().mockResolvedValue(undefined) + applySessionSearchSettingsChangeMock.mockClear() listProfilesMock.mockReset().mockReturnValue([]) browserWindowGetAllWindowsMock.mockReset() store.getSettings.mockReset() @@ -827,4 +834,44 @@ describe('registerSettingsHandlers', () => { expect(rebuildAppMenuMock).toHaveBeenCalledTimes(1) }) + + // 3b stores the two booleans and nothing else; the consent copy and the + // history picker are PR 8's. A profile that has never opted in has no key. + it('normalizes an agent-session-search write and hands the change to the index', async () => { + const before = { aiVaultSearch: { enabled: false, historyDays: null } } + store.getSettings.mockReturnValue(before) + store.updateSettings.mockImplementation((args: object) => ({ ...before, ...args })) + registerSettingsHandlers(store as never) + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + event: typeof settingsInvokeEvent, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { + aiVaultSearch: { enabled: true, historyDays: 30.7, paused: true } + }) + + expect(store.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ aiVaultSearch: { enabled: true, historyDays: 30 } }), + expect.anything() + ) + expect(applySessionSearchSettingsChangeMock).toHaveBeenCalledWith( + before, + expect.objectContaining({ aiVaultSearch: { enabled: true, historyDays: 30 } }) + ) + }) + + it('leaves the index alone for a settings write that does not mention it', async () => { + store.getSettings.mockReturnValue({ appIcon: 'default' }) + store.updateSettings.mockReturnValue({ appIcon: 'default' }) + registerSettingsHandlers(store as never) + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + event: typeof settingsInvokeEvent, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { appIcon: 'default' }) + + expect(applySessionSearchSettingsChangeMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 1d4194825d9..f3c1b8aeaf8 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -36,6 +36,8 @@ import { computerAwakeSettingsForMode, normalizeComputerAwakeMode } from '../../shared/computer-awake-mode' +import { resolveAiVaultSearchSettings } from '../../shared/ai-vault-search-settings' +import { applySessionSearchSettingsChange } from '../ai-vault-search/session-search-enablement' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -160,6 +162,9 @@ export function registerSettingsHandlers( if ('appIcon' in args) { sanitizedArgs.appIcon = normalizeAppIconId(args.appIcon) } + if ('aiVaultSearch' in args) { + sanitizedArgs.aiVaultSearch = resolveAiVaultSearchSettings(args) + } if ('terminalCustomThemes' in args) { sanitizedArgs.terminalCustomThemes = normalizeTerminalCustomThemes(args.terminalCustomThemes) } @@ -266,6 +271,9 @@ export function registerSettingsHandlers( if ('appIcon' in sanitizedArgs && before.appIcon !== result.appIcon) { applyAppIcon(result.appIcon) } + if ('aiVaultSearch' in sanitizedArgs) { + applySessionSearchSettingsChange(before, result) + } // Why: telemetry-plan.md§Settings — fire `settings_changed` only for // whitelisted keys, with `value_kind` distinguishing booleans from diff --git a/src/main/orcad/orcad-command-arguments.ts b/src/main/orcad/orcad-command-arguments.ts new file mode 100644 index 00000000000..f4fd748de61 --- /dev/null +++ b/src/main/orcad/orcad-command-arguments.ts @@ -0,0 +1,43 @@ +import type { OrcadOptions } from './orcad-entry' + +/** + * orcad's flags. A value-taking flag consumes the next token whatever it looks + * like, so `--bind --json` binds to the literal `--json`; only a missing token + * is an error. Pinned by orcad-launch-contract.test.ts. + */ +export function parseArgs(argv: string[]): OrcadOptions { + const options: OrcadOptions = {} + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--port') { + const raw = argv[i + 1] + const port = Number(raw) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`--port expects an integer 0-65535, got ${raw ?? "''"}`) + } + options.port = port + i += 1 + } else if (arg === '--json') { + options.json = true + } else if (arg === '--no-pairing') { + options.noPairing = true + } else if (arg === '--bind') { + const value = argv[i + 1] + if (value === undefined) { + throw new Error('--bind expects a value') + } + options.bind = value + i += 1 + } else if (arg === '--pairing-address') { + const value = argv[i + 1] + if (!value) { + throw new Error('--pairing-address expects a value') + } + options.pairingAddress = value + i += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + return options +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 137894f87b8..3dc84906c99 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -25,6 +25,9 @@ import { } from './orcad-bind-address' import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' import { startOrcadWithLifecycle } from './orcad-lifecycle' +import { parseArgs } from './orcad-command-arguments' + +export { parseArgs } let runOrcadQuitHandlers = (): void => {} @@ -242,6 +245,13 @@ async function startOrcadRuntime( isAgentStatusHooksEnabled(store.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) + const { installOrcadSessionSearchService } = await import('./orcad-session-search') + const sessionSearch = await installOrcadSessionSearchService({ + userDataPath: runtimeUserDataPath, + getSettings: () => store.getSettings() + }) + getAppEnvironment().onWillQuit(() => sessionSearch?.dispose()) + // Why here too and not only on the desktop: nothing else republishes `session.tabs` when a // pane's status row changes, and orcad's whole job is serving paired clients. uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( @@ -338,43 +348,6 @@ async function startOrcadRuntime( return { readiness } } -export function parseArgs(argv: string[]): OrcadOptions { - const options: OrcadOptions = {} - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i] - if (arg === '--port') { - const raw = argv[i + 1] - const port = Number(raw) - if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new Error(`--port expects an integer 0-65535, got ${raw ?? "''"}`) - } - options.port = port - i += 1 - } else if (arg === '--json') { - options.json = true - } else if (arg === '--no-pairing') { - options.noPairing = true - } else if (arg === '--bind') { - const value = argv[i + 1] - if (value === undefined) { - throw new Error('--bind expects a value') - } - options.bind = value - i += 1 - } else if (arg === '--pairing-address') { - const value = argv[i + 1] - if (!value) { - throw new Error('--pairing-address expects a value') - } - options.pairingAddress = value - i += 1 - } else { - throw new Error(`Unknown argument: ${arg}`) - } - } - return options -} - /** * Exit codes a supervisor can act on. Closed set — see docs/reference/orcad-operations.md. * diff --git a/src/main/orcad/orcad-session-search.ts b/src/main/orcad/orcad-session-search.ts new file mode 100644 index 00000000000..7f5d67010a8 --- /dev/null +++ b/src/main/orcad/orcad-session-search.ts @@ -0,0 +1,26 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { resolveAiVaultSearchSettings } from '../../shared/ai-vault-search-settings' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { localAiVaultScanRoots } from '../ai-vault/cached-session-list' +import { installInProcessSessionSearchService } from '../ai-vault-search/session-search-in-process-service' + +/** + * orcad's session search registration. + * + * In this process and not a scanner child: orcad ships only the watcher and the + * daemon entries beside `orcad.js`, so there is no scanner-service child here to + * own the index — and this process is the sole writer, so nothing can race it. + * Null on a host whose Node has no `node:sqlite`, which is orcad's stated floor. + */ +export async function installOrcadSessionSearchService(args: { + userDataPath: string + getSettings: () => Pick +}): Promise<{ dispose(): void } | null> { + return installInProcessSessionSearchService({ + dataRoot: args.userDataPath, + roots: { executionHostId: LOCAL_EXECUTION_HOST_ID }, + resolveRoots: localAiVaultScanRoots, + settings: resolveAiVaultSearchSettings(args.getSettings()), + onError: (error) => console.error('[orcad] session search:', error) + }) +} diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 3aac4a03b3c..a0a65d54e7a 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -1,3 +1,5 @@ +import { installChildSessionSearchService } from '../ai-vault-search/session-search-enablement' +import { getCanonicalUserDataPath } from '../persistence/loading-store/user-data-path' import { app } from 'electron' import { OrcaRuntimeService } from '../runtime/orca-runtime' import { getLocalPtyProvider, getSshPtyProvider, clearProviderPtyState } from '../ipc/pty' @@ -131,6 +133,12 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { orchestrationEnvironmentTransport, skillTransactionRecovery: state.skillTransactionRecovery }) + // Both desktop and headless serve own a host-local search service. + const sessionSearch = installChildSessionSearchService({ + dataRoot: getCanonicalUserDataPath(), + getSettings: () => store.getSettings() + }) + app.once('will-quit', () => sessionSearch?.dispose()) state.runtime = runtime agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime) diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index 73e03242af6..4295014782f 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -1,6 +1,10 @@ import { homedir } from 'node:os' +import { join } from 'node:path' import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' +import { parseUnameToRelayPlatform, RELAY_REMOTE_DIR } from '../main/ssh/relay-protocol' +import { DEFAULT_AI_VAULT_SEARCH_SETTINGS } from '../shared/ai-vault-search-settings' +import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { installInProcessSessionSearchService } from '../main/ai-vault-search/session-search-in-process-service' import type { RelayDispatcher } from './dispatcher' import { RelayContext, expandTilde } from './context' import { PtyHandler } from './pty-handler' @@ -29,6 +33,7 @@ export class RelayRuntimeServices { readonly gitHandler: GitHandler readonly skillInstallHandler: SkillInstallHandler private readonly aiVaultService: ReturnType | null + private readonly sessionSearch: { dispose(): void } | null private readonly registeredHandlers: readonly unknown[] constructor( @@ -77,6 +82,22 @@ export class RelayRuntimeServices { const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined this.aiVaultService = hostPlatform ? createRelayAiVaultService(homedir(), hostPlatform) : null + // Why beside the AI Vault sidecar and not inside it: that sidecar runs the + // remote scanner, which reads through a filesystem provider and publishes + // nothing to the transcript channel the index consumes. This process is the + // one that would drive the index's own reads, and the only writer on the file. + // Off until something can carry consent to a remote host (see the PR body); + // registering it anyway is what makes this host answer `disabled` and not + // `no-service`, which is the difference between off and too old. + this.sessionSearch = installInProcessSessionSearchService({ + dataRoot: join(homedir(), RELAY_REMOTE_DIR), + roots: { executionHostId: LOCAL_EXECUTION_HOST_ID }, + settings: DEFAULT_AI_VAULT_SEARCH_SETTINGS, + onError: (error) => + relayLogLine( + `[relay] session search: ${error instanceof Error ? error.message : String(error)}` + ) + }) this.registeredHandlers = [ preflightHandler, this.skillInstallHandler, @@ -112,6 +133,7 @@ export class RelayRuntimeServices { } disposeHandlers(): void { + this.sessionSearch?.dispose() this.fsHandler.dispose() this.gitHandler.dispose() void this.registeredHandlers diff --git a/src/shared/ai-vault-search-settings.test.ts b/src/shared/ai-vault-search-settings.test.ts new file mode 100644 index 00000000000..78ff95fa2f3 --- /dev/null +++ b/src/shared/ai-vault-search-settings.test.ts @@ -0,0 +1,81 @@ +import { expect, it } from 'vitest' +import { + AiVaultSearchSettingsSchema, + DEFAULT_AI_VAULT_SEARCH_SETTINGS, + resolveAiVaultSearchSettings, + sameAiVaultSearchSettings +} from './ai-vault-search-settings' + +// Off is the only safe default: building the index reads every transcript on the +// machine, so a profile that has never answered must read as "no". +it('reads anything that is not an explicit opt-in as off', () => { + expect(resolveAiVaultSearchSettings(undefined)).toEqual(DEFAULT_AI_VAULT_SEARCH_SETTINGS) + expect(resolveAiVaultSearchSettings({})).toEqual(DEFAULT_AI_VAULT_SEARCH_SETTINGS) + expect(resolveAiVaultSearchSettings({ aiVaultSearch: null })).toEqual( + DEFAULT_AI_VAULT_SEARCH_SETTINGS + ) + expect(resolveAiVaultSearchSettings({ aiVaultSearch: { enabled: 'yes' } })).toEqual( + DEFAULT_AI_VAULT_SEARCH_SETTINGS + ) + expect(resolveAiVaultSearchSettings({ aiVaultSearch: 'on' })).toEqual( + DEFAULT_AI_VAULT_SEARCH_SETTINGS + ) +}) + +it('normalizes a history bound and drops anything that is not one', () => { + expect( + resolveAiVaultSearchSettings({ aiVaultSearch: { enabled: true, historyDays: 30.7 } }) + ).toEqual({ enabled: true, historyDays: 30 }) + // A fractional day floors to zero, which would read as "all history" on one + // side and "cutoff is now" on the other. + for (const historyDays of [0.4, 0, -30, Number.NaN] as const) { + expect(resolveAiVaultSearchSettings({ aiVaultSearch: { enabled: true, historyDays } })).toEqual( + { enabled: true, historyDays: null } + ) + } + expect( + resolveAiVaultSearchSettings({ aiVaultSearch: { enabled: true, historyDays: 999_999 } }) + ).toEqual({ enabled: true, historyDays: 3_650 }) +}) + +// There is no `paused`: the indexer is immutable, so a pause would be a second +// lifetime for one object's store, queue and sweep flag. +it('keeps only the two fields the indexer is constructed from', () => { + expect( + resolveAiVaultSearchSettings({ + aiVaultSearch: { enabled: true, historyDays: 90, paused: true } + }) + ).toEqual({ enabled: true, historyDays: 90 }) +}) + +it('accepts what it produces and refuses what it does not', () => { + expect(AiVaultSearchSettingsSchema.parse({ enabled: true, historyDays: 90 })).toEqual({ + enabled: true, + historyDays: 90 + }) + expect(AiVaultSearchSettingsSchema.safeParse({ enabled: true, historyDays: 0 }).success).toBe( + false + ) + expect(AiVaultSearchSettingsSchema.safeParse({ historyDays: null }).success).toBe(false) +}) + +it('treats an unchanged policy as unchanged so a re-save never restarts the index', () => { + expect( + sameAiVaultSearchSettings( + { enabled: true, historyDays: 30 }, + { enabled: true, historyDays: 30 } + ) + ).toBe(true) + expect( + sameAiVaultSearchSettings( + { enabled: true, historyDays: 30 }, + { enabled: true, historyDays: 90 } + ) + ).toBe(false) + expect( + sameAiVaultSearchSettings( + { enabled: true, historyDays: null }, + { enabled: false, historyDays: null } + ) + ).toBe(false) +}) diff --git a/src/shared/ai-vault-search-settings.ts b/src/shared/ai-vault-search-settings.ts new file mode 100644 index 00000000000..8b6b8e1b07c --- /dev/null +++ b/src/shared/ai-vault-search-settings.ts @@ -0,0 +1,65 @@ +import { z } from 'zod' + +/** + * Consent and retention for the agent-session transcript index. + * + * Off until the user turns it on: building the index reads every transcript on + * the machine, so nothing constructs an indexer, opens the database or reads a + * transcript for it before that choice is recorded. + * + * There is no `paused`. The indexer is immutable after construction, so every + * change here is close-and-construct (see session-search-instance.ts). + */ +export type AiVaultSearchSettings = { + enabled: boolean + /** null = all history; otherwise only transcripts modified within this many days. */ + historyDays: number | null +} + +export const DEFAULT_AI_VAULT_SEARCH_SETTINGS: AiVaultSearchSettings = { + enabled: false, + historyDays: null +} + +const HISTORY_DAYS_MAX = 3_650 + +export const AiVaultSearchSettingsSchema: z.ZodType = z.object({ + enabled: z.boolean(), + historyDays: z.number().int().positive().max(HISTORY_DAYS_MAX).nullable() +}) + +export function normalizeAiVaultSearchHistoryDays(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return null + } + // A fractional day floors to 0, which reads as "all history" on one side and + // "now" on the other; make the two agree. + const days = Math.floor(value) + return days <= 0 ? null : Math.min(HISTORY_DAYS_MAX, days) +} + +/** + * The persisted shape, from whatever a settings write or an old profile left behind. + * + * The input is `unknown` on purpose: this is the sanitizer, and what it reads is a + * JSON profile that may predate either field or hold a value no version wrote. + */ +export function resolveAiVaultSearchSettings( + settings: { aiVaultSearch?: unknown } | null | undefined +): AiVaultSearchSettings { + const raw = settings?.aiVaultSearch + if (typeof raw !== 'object' || raw === null) { + return { ...DEFAULT_AI_VAULT_SEARCH_SETTINGS } + } + return { + enabled: 'enabled' in raw && raw.enabled === true, + historyDays: normalizeAiVaultSearchHistoryDays('historyDays' in raw ? raw.historyDays : null) + } +} + +export function sameAiVaultSearchSettings( + a: AiVaultSearchSettings, + b: AiVaultSearchSettings +): boolean { + return a.enabled === b.enabled && a.historyDays === b.historyDays +} diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index e65fec28c39..6369033c892 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -1,6 +1,7 @@ import type { ExecutionHostId } from './execution-host' import type { GitHubProjectSettings } from './github/project-types' import type { VoiceSettings } from './speech-types' +import type { AiVaultSearchSettings } from './ai-vault-search-settings' import type { GitLabProjectSettings } from './gitlab-types' import type { TaskProvider } from './task-providers' import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings' @@ -488,6 +489,8 @@ export type GlobalSettings = { tabSwitchKeybindingSeed?: 'pending' | 'done' /** Local voice/dictation config. Optional for pre-voice profiles; getDefaultSettings() hydrates defaults via the persistence merge. */ voice?: VoiceSettings + /** Transcript full-text search consent + retention. Absent means off; nothing indexes until the user opts in. */ + aiVaultSearch?: AiVaultSearchSettings } export type OrcaWorkspaceLayout = { From c6a72169843ececf3a21da370ac50c5c5a4e6462 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:42:38 -0700 Subject: [PATCH 03/43] fix(native-chat): hide activity while awaiting input (#20496) * fix(native-chat): hide activity while awaiting input * fix(native-chat): keep approval turns cancellable * test(native-chat): satisfy split PR quality gate * fix(native-chat): catalog approval cancellation label * fix(native-chat): include approval cancellation runtime label * fix(codex): settle prompts when cancelled turns complete * fix(codex): settle prompt registry fallbacks * test(native-chat): cover pending interaction fallbacks * test(native-chat): split prompt state coverage * test(native-chat): keep prompt state isolated * fix(native-chat): bound prompt turn backfill * refactor(codex): centralize prompt registry bounds --------- Co-authored-by: Merge Sim --- .../src/session/MobileNativeChatView.test.ts | 69 ++++++++++ mobile/src/session/MobileNativeChatView.tsx | 7 +- .../codex/codex-prompt-registry-bounds.ts | 47 +++++++ .../codex-structured-journal-contracts.ts | 8 +- .../codex/codex-structured-journal-prompts.ts | 25 +++- .../codex-structured-journal-settlement.ts | 83 ++++-------- .../codex/codex-structured-journal-sink.ts | 54 +++++++- ...red-journal-translation-turn-boundaries.ts | 7 +- ...journal-translation-turn-lifecycle.test.ts | 122 ++++++++++++++++++ .../codex-structured-journal-translation.ts | 8 +- .../codex-structured-prompt-replies.test.ts | 44 +++++++ .../codex/codex-structured-prompt-replies.ts | 47 ++++--- .../codex/codex-structured-session-acquire.ts | 5 +- .../codex/codex-structured-session-adapter.ts | 14 +- .../NativeChatApprovalCard.test.tsx | 29 +++++ .../native-chat/NativeChatApprovalCard.tsx | 20 ++- .../native-chat/NativeChatMessageList.tsx | 5 +- ...iveChatMessageList.turn-indicator.test.tsx | 37 ++++++ ...tiveChatStructuredSession.test-harness.tsx | 33 +++-- .../NativeChatStructuredSession.test.tsx | 99 ++++++++++++++ .../NativeChatStructuredSession.tsx | 6 + ...ctured-agent-session-prompt-state.test.tsx | 78 +++++++++++ .../use-structured-agent-session.test.tsx | 1 - .../src/i18n/en-runtime-required.json | 3 + src/renderer/src/i18n/locales/en.json | 3 +- 25 files changed, 749 insertions(+), 105 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 2e67b1eaa9f..a245a1fe7bb 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -85,6 +85,9 @@ type Overrides = { turnIndicator?: Parameters[0]['turnIndicator'] agentWorking?: boolean canStop?: boolean + ask?: Parameters[0]['ask'] + question?: Parameters[0]['question'] + permission?: Parameters[0]['permission'] sendSurfaceId?: string keyboardInset?: number hasMore?: boolean @@ -681,6 +684,72 @@ describe('MobileNativeChatView', () => { expect(workingIndicators()).toHaveLength(0) }) + it.each([ + { + label: 'structured question', + cardType: 'ChatAsk', + interaction: { + ask: { + questions: [ + { + question: 'Pick destination', + multiSelect: false, + options: [{ label: 'Choice A' }, { label: 'Choice B' }] + } + ] + } + } + }, + { + label: 'question', + cardType: 'ChatQuestion', + interaction: { + question: { + question: 'Pick destination', + options: ['Choice A', 'Choice B'], + multiSelect: false, + allowOther: true, + optionTokens: ['choice-a', 'choice-b'] + } + } + }, + { + label: 'approval', + cardType: 'ChatPermission', + interaction: { + permission: { + title: 'Allow command?', + detail: 'pnpm test', + options: [ + { label: 'Allow', send: 'allow' }, + { label: 'Deny', send: 'deny' } + ] + } + } + } + ])('hides live turn activity for a pending $label without settling it', async (testCase) => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'waiting for input')] + const working = { + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + canStop: true + } + await render({ ...working, ...testCase.interaction }) + + expect(footerProps()).toBeNull() + expect(rowProps('a1').activeTurnIsWorking).toBe(true) + expect( + renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Stop the agent') + ).toHaveLength(1) + expect(renderer!.root.findAll((node) => node.type === testCase.cardType)).toHaveLength(1) + + await update(working) + expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null }) + expect(rowProps('a1').activeTurnIsWorking).toBe(true) + }) + it('reports the live turn as thinking only when its journal says it is reasoning', async () => { const folded = [userTurn('u1', 'go')] await render({ diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index be974b989f3..28d2f871cdf 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -266,6 +266,8 @@ export function MobileNativeChatView({ activityText: turnIndicator?.activityText ?? null, scopeKey: sendSurfaceId }) + const hasPendingStructuredInteraction = + structuredActivityUi && (ask != null || permission != null || question != null) const renderItem = useCallback( ({ item, index }: { item: NativeChatMessage; index: number }) => ( @@ -329,7 +331,10 @@ export function MobileNativeChatView({ ) : null } ListFooterComponent={ - structuredActivityUi && agentWorking && turns.active ? ( + structuredActivityUi && + agentWorking && + !hasPendingStructuredInteraction && + turns.active ? ( + answers: ReadonlyMap +} + +export function codexPromptRegistryEntryBytes(prompt: CodexPromptRegistryEntryBounds): number { + let bytes = 0 + for (const value of [prompt.threadId, prompt.codexItemId, prompt.promptKey]) { + bytes += Buffer.byteLength(value, 'utf8') + } + const turnId = prompt.turnId ?? prompt.turnIdDigest + bytes += turnId ? Buffer.byteLength(turnId, 'utf8') : CODEX_PROMPT_TURN_ID_RESERVED_BYTES + for (const id of prompt.questionIds) { + bytes += Buffer.byteLength(id, 'utf8') + } + for (const entry of prompt.optionAnswers.values()) { + bytes += Buffer.byteLength(entry.questionId, 'utf8') + Buffer.byteLength(entry.answer, 'utf8') + } + for (const value of prompt.answers.values()) { + bytes += Buffer.byteLength(value, 'utf8') + } + return bytes +} + +export function codexPromptTurnIdentity(turnId: string): { + turnId: string | null + turnIdDigest?: string +} { + return Buffer.byteLength(turnId, 'utf8') <= CODEX_PROMPT_TURN_ID_RESERVED_BYTES + ? { turnId } + : { turnId: null, turnIdDigest: digestPayload(turnId) } +} + +export function codexPromptMatchesTurn( + prompt: Pick, + turnId: string +): boolean { + return prompt.turnId === turnId || prompt.turnIdDigest === digestPayload(turnId) +} export function codexJournalPromptIdPart(value: string): string { if (Buffer.byteLength(value, 'utf8') <= CODEX_JOURNAL_PROMPT_ID_COMPONENT_MAX_BYTES) { diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index d7be380446f..d7a902c9484 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -8,7 +8,13 @@ export type CodexJournalTranslatorDeps = { /** Keys restored lifecycle rows to the live identity; without it history restore skips them. */ sessionId?: string now?: () => number - bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void + bindPromptItemId?: ( + journalItemId: string, + threadId: string, + promptKey: string, + turnId?: string | null + ) => void + clearPromptTurn?: (threadId: string, turnId: string) => void primaryThreadId?: () => string | null subagentExecutions?: CodexSubagentExecutions coalesceMs?: number diff --git a/src/main/codex/codex-structured-journal-prompts.ts b/src/main/codex/codex-structured-journal-prompts.ts index 93ecec77f77..3a3f57576cb 100644 --- a/src/main/codex/codex-structured-journal-prompts.ts +++ b/src/main/codex/codex-structured-journal-prompts.ts @@ -18,13 +18,15 @@ import { publishCodexLifecycle } from './codex-structured-journal-sink' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' +import { readCodexTurnId } from './codex-structured-thread-facts' export class CodexJournalPrompts { readonly pending = new Map() constructor( private readonly deps: Pick, - private readonly detailFor: (threadId: string, itemId: string) => string | null + private readonly detailFor: (threadId: string, itemId: string) => string | null, + private readonly activeTurn: (threadId: string) => string | null ) {} handle(event: { @@ -34,6 +36,7 @@ export class CodexJournalPrompts { codexItemId: string promptKey: string }): CodexJournalTranslationAdmission { + const turnId = readCodexTurnId(event.params) ?? this.activeTurn(event.threadId) if (event.method === CODEX_USER_INPUT_METHOD) { const questions = codexQuestionItems({ threadId: event.threadId, @@ -47,12 +50,17 @@ export class CodexJournalPrompts { } for (const question of promptItems) { const itemId = agentJournalItemKey(question.identity) - this.pending.set(itemId, { identity: question.identity, body: question.body }) + this.pending.set(itemId, { + threadId: event.threadId, + turnId, + identity: question.identity, + body: question.body + }) const trimAdmission = this.trim() if (!trimAdmission.accepted) { return trimAdmission } - this.deps.bindPromptItemId?.(itemId, event.threadId, event.promptKey) + this.deps.bindPromptItemId?.(itemId, event.threadId, event.promptKey, turnId) } return CODEX_JOURNAL_ADMITTED } @@ -70,12 +78,17 @@ export class CodexJournalPrompts { return admission } const itemId = agentJournalItemKey(identity) - this.pending.set(itemId, { identity, body }) + this.pending.set(itemId, { + threadId: event.threadId, + turnId, + identity, + body + }) const trimAdmission = this.trim() if (!trimAdmission.accepted) { return trimAdmission } - this.deps.bindPromptItemId?.(itemId, event.threadId, event.promptKey) + this.deps.bindPromptItemId?.(itemId, event.threadId, event.promptKey, turnId) return CODEX_JOURNAL_ADMITTED } @@ -89,7 +102,7 @@ export class CodexJournalPrompts { private admit( event: { method: string; threadId: string; promptKey: string }, - items: readonly CodexPendingJournalPrompt[] + items: readonly Pick[] ): CodexJournalTranslationAdmission { return admitCodexLifecycleItems( this.deps.sink, diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 5aa158fafc7..5322d3355cd 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -3,7 +3,6 @@ import type { AgentJournalItemIdentity, AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' -import { partitionJournalLifecycleMutations } from '../native-chat/agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../native-chat/agent-session-journal/journal-row-builders' import type { StructuredAgentSessionEventSink, @@ -23,6 +22,7 @@ import { codexTurnLifecycleBody, codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' +import { appendCodexLifecycleMutations } from './codex-structured-journal-sink' export type CodexActiveJournalItem = { threadId: string @@ -32,6 +32,8 @@ export type CodexActiveJournalItem = { } export type CodexPendingJournalPrompt = { + threadId: string + turnId: string | null identity: AgentJournalItemIdentity body: AgentJournalItemBody } @@ -85,7 +87,11 @@ export function settleCodexJournalSession(input: { turnOrdinalsToForget.push({ threadId, turnId }) } } - const admission = appendLifecycleMutations(input.sink, exitSettlementId(input.event), mutations) + const admission = appendCodexLifecycleMutations( + input.sink, + exitSettlementId(input.event), + mutations + ) if (!admission.accepted) { return admission } @@ -104,9 +110,13 @@ export function settleCodexJournalTurn(input: { sink: StructuredAgentSessionEventSink streams: CodexStructuredItemStreams activeItems: Map + pendingPrompts?: Map + clearPromptTurn?: (threadId: string, turnId: string) => void }): StructuredAgentSessionSinkAdmission { const mutations: JournalLifecycleMutationInput[] = [] const activeItemsToForget: { key: string; threadId: string; itemId: string }[] = [] + const pendingPromptsToForget: string[] = [] + const pendingPrompts = input.pendingPrompts ?? new Map() for (const [key, active] of input.activeItems) { if (active.threadId !== input.threadId || active.turnId !== input.turnId) { continue @@ -124,6 +134,16 @@ export function settleCodexJournalTurn(input: { } activeItemsToForget.push({ key, threadId: active.threadId, itemId: active.item.id }) } + for (const [key, prompt] of pendingPrompts) { + if (prompt.threadId !== input.threadId || prompt.turnId !== input.turnId) { + continue + } + const body = cancelledJournalPromptBody(prompt.body) + if (body) { + mutations.push({ kind: 'item', identity: prompt.identity, body }) + } + pendingPromptsToForget.push(key) + } // Revised, never tombstoned: the terminal row keeps the turn's duration durable. if (input.turnLifecycle) { mutations.push({ @@ -132,10 +152,7 @@ export function settleCodexJournalTurn(input: { body: codexTurnLifecycleBody(input.turnLifecycle) }) } - if (mutations.length === 0) { - return ADMITTED - } - const admission = appendLifecycleMutations( + const admission = appendCodexLifecycleMutations( input.sink, `turn-completed:${input.sessionId}:${input.threadId}:${input.turnId}`, mutations @@ -147,6 +164,10 @@ export function settleCodexJournalTurn(input: { input.streams.forget(active.threadId, active.itemId) input.activeItems.delete(active.key) } + for (const key of pendingPromptsToForget) { + pendingPrompts.delete(key) + } + input.clearPromptTurn?.(input.threadId, input.turnId) return ADMITTED } @@ -182,7 +203,7 @@ export function settleCodexOversizedNotification(input: { if (mutations.length === 0) { return ADMITTED } - const admission = appendLifecycleMutations( + const admission = appendCodexLifecycleMutations( input.sink, `oversized-notification:${input.sessionId}:${input.threadId}:${input.method}`, mutations @@ -225,54 +246,6 @@ function oversizedStreamItemType(method: string): CodexThreadItem['type'] | null return null } -function appendLifecycleMutations( - sink: StructuredAgentSessionEventSink, - settlementId: string, - mutations: readonly JournalLifecycleMutationInput[] -): StructuredAgentSessionSinkAdmission { - const chunks = partitionJournalLifecycleMutations(settlementId, mutations) - for (const { settlementId: id, mutations: chunk } of chunks) { - let admission: StructuredAgentSessionSinkAdmission = ADMITTED - if (sink.tryAppendLifecycleBatch) { - admission = sink.tryAppendLifecycleBatch(id, chunk, { lifecycle: true }) - } else if (sink.appendLifecycleBatch) { - admission = sink.appendLifecycleBatch(id, chunk, { lifecycle: true }) ?? ADMITTED - } else { - for (const mutation of chunk) { - if (mutation.kind === 'item') { - if (sink.tryAppendItem) { - admission = sink.tryAppendItem(mutation.identity, mutation.body, { lifecycle: true }) - if (!admission.accepted) { - return admission - } - } else { - sink.appendItem(mutation.identity, mutation.body, { lifecycle: true }) - } - } else { - if (sink.tryAppendTombstone) { - admission = sink.tryAppendTombstone(mutation.identity, { lifecycle: true }) - if (!admission.accepted) { - return admission - } - } else { - sink.appendTombstone(mutation.identity, { lifecycle: true }) - } - } - } - } - if (!admission.accepted) { - return admission - } - const publishAdmission = sink.tryPublish - ? sink.tryPublish({ lifecycle: true }) - : (sink.publish({ lifecycle: true }), ADMITTED) - if (!publishAdmission.accepted) { - return publishAdmission - } - } - return ADMITTED -} - function interruptedBody(body: AgentJournalItemBody | null): AgentJournalItemBody | null { if (!body) { return null diff --git a/src/main/codex/codex-structured-journal-sink.ts b/src/main/codex/codex-structured-journal-sink.ts index 7da381def41..5b8f4b83920 100644 --- a/src/main/codex/codex-structured-journal-sink.ts +++ b/src/main/codex/codex-structured-journal-sink.ts @@ -7,10 +7,62 @@ import type { StructuredAgentSessionLifecycleIdentityResolver, StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { partitionJournalLifecycleMutations } from '../native-chat/agent-session-journal/journal-lifecycle-batch-partition' +import type { JournalLifecycleMutationInput } from '../native-chat/agent-session-journal/journal-row-builders' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' import { CODEX_JOURNAL_ADMITTED } from './codex-structured-journal-contracts' +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +export function appendCodexLifecycleMutations( + sink: StructuredAgentSessionEventSink, + settlementId: string, + mutations: readonly JournalLifecycleMutationInput[] +): StructuredAgentSessionSinkAdmission { + const chunks = partitionJournalLifecycleMutations(settlementId, mutations) + for (const { settlementId: id, mutations: chunk } of chunks) { + let admission: StructuredAgentSessionSinkAdmission = ADMITTED + if (sink.tryAppendLifecycleBatch) { + admission = sink.tryAppendLifecycleBatch(id, chunk, { lifecycle: true }) + } else if (sink.appendLifecycleBatch) { + admission = sink.appendLifecycleBatch(id, chunk, { lifecycle: true }) ?? ADMITTED + } else { + for (const mutation of chunk) { + if (mutation.kind === 'item') { + if (sink.tryAppendItem) { + admission = sink.tryAppendItem(mutation.identity, mutation.body, { lifecycle: true }) + if (!admission.accepted) { + return admission + } + } else { + sink.appendItem(mutation.identity, mutation.body, { lifecycle: true }) + } + } else { + if (sink.tryAppendTombstone) { + admission = sink.tryAppendTombstone(mutation.identity, { lifecycle: true }) + if (!admission.accepted) { + return admission + } + } else { + sink.appendTombstone(mutation.identity, { lifecycle: true }) + } + } + } + } + if (!admission.accepted) { + return admission + } + const publishAdmission = sink.tryPublish + ? sink.tryPublish({ lifecycle: true }) + : (sink.publish({ lifecycle: true }), ADMITTED) + if (!publishAdmission.accepted) { + return publishAdmission + } + } + return ADMITTED +} + function criticalAdmission( admission: StructuredAgentSessionSinkAdmission ): CodexJournalTranslationAdmission { @@ -57,7 +109,7 @@ export function publishCodexLifecycle( export function admitCodexLifecycleItems( sink: StructuredAgentSessionEventSink, settlementId: string, - items: readonly CodexPendingJournalPrompt[] + items: readonly Pick[] ): CodexJournalTranslationAdmission { if (items.length === 0) { return { accepted: false, reason: 'untranslated' } diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts index a4aa3200aca..014ce81ed96 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -12,6 +12,7 @@ import { codexTurnUserItemId, publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' +import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' import { readCodexTurnDurationMs, readCodexTurnId, @@ -33,6 +34,8 @@ export class CodexJournalTurnBoundaries { primaryThreadId: () => string | null activeTurns: CodexJournalActiveTurns items: Pick + pendingPrompts: Map + clearPromptTurn?: (threadId: string, turnId: string) => void flushSuppression: () => CodexJournalTranslationAdmission resetActivity: (threadId: string) => void now?: () => number @@ -93,7 +96,9 @@ export class CodexJournalTurnBoundaries { ) : null, streams: this.deps.items.streams, - activeItems: this.deps.items.activeItems + activeItems: this.deps.items.activeItems, + pendingPrompts: this.deps.pendingPrompts, + ...(this.deps.clearPromptTurn ? { clearPromptTurn: this.deps.clearPromptTurn } : {}) }) if (admission.accepted) { this.deps.items.ordinals.forgetTurn(event.threadId, turnId) diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index b57670851b5..9ddeca4fba7 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -14,6 +14,11 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexAppServerConnection } from './codex-app-server-connection' import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CodexPromptRegistry +} from './codex-structured-prompt-replies' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexSession } from './codex-structured-session-state' @@ -85,6 +90,123 @@ afterEach(async () => { }) describe('codex turn lifecycle rows', () => { + it('binds a prompt without a provider turn id to the active turn before cleanup', () => { + const tap = recorder() + const registry = new CodexPromptRegistry() + registry.register({ + id: 1, + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { + itemId: 'exec-fallback', + approvalId: 'approval-fallback', + threadId: THREAD_ID + } + }) + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID, + bindPromptItemId: (journalItemId, threadId, promptKey, turnId) => + registry.bindJournalItemId(journalItemId, threadId, promptKey, turnId), + clearPromptTurn: (threadId, turnId) => registry.clearTurn(threadId, turnId) + }) + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + translator.handle({ + type: 'prompt', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { availableDecisions: ['accept', 'decline'] }, + codexItemId: 'exec-fallback', + promptKey: 'approval-fallback' + }) + + expect(registry.find('approval-fallback')?.turnId).toBe(TURN_ID) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + expect(registry.find('approval-fallback')).toBeNull() + }) + + it('settles prompts when a turn completes while awaiting approval', () => { + const tap = recorder() + const clearPromptTurn = vi.fn() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID, + clearPromptTurn + }) + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + translator.handle({ + type: 'prompt', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { turnId: TURN_ID, availableDecisions: ['accept', 'decline'] }, + codexItemId: 'exec-cancelled', + promptKey: 'approval-cancelled' + }) + + expect(translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))).toEqual({ + accepted: true + }) + expect(tap.rows.map((row) => row.body)).toEqual([ + expect.objectContaining({ kind: 'turn', state: 'running' }), + expect.objectContaining({ + kind: 'approval', + resolution: expect.objectContaining({ state: 'pending' }) + }), + expect.objectContaining({ + kind: 'approval', + resolution: expect.objectContaining({ state: 'cancelled' }) + }), + expect.objectContaining({ kind: 'turn', state: 'completed' }) + ]) + expect(clearPromptTurn).toHaveBeenCalledWith(THREAD_ID, TURN_ID) + }) + + it('settles questions when a turn completes while awaiting input', () => { + const tap = recorder() + const clearPromptTurn = vi.fn() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID, + clearPromptTurn + }) + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + translator.handle({ + type: 'prompt', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: CODEX_USER_INPUT_METHOD, + params: { + turnId: TURN_ID, + questions: [ + { id: 'question-cancelled', question: 'Continue?', options: [{ label: 'yes' }] } + ] + }, + codexItemId: 'exec-question-cancelled', + promptKey: 'question-cancelled' + }) + + expect(translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))).toEqual({ + accepted: true + }) + expect(tap.rows.map((row) => row.body)).toEqual([ + expect.objectContaining({ kind: 'turn', state: 'running' }), + expect.objectContaining({ + kind: 'question', + resolution: expect.objectContaining({ state: 'pending' }) + }), + expect.objectContaining({ + kind: 'question', + resolution: expect.objectContaining({ state: 'cancelled' }) + }), + expect.objectContaining({ kind: 'turn', state: 'completed' }) + ]) + expect(clearPromptTurn).toHaveBeenCalledWith(THREAD_ID, TURN_ID) + }) + it('opens the running row with the host receipt time and pins the row time to it', async () => { const journal = await journals.open({ identity: { diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 5c1e97310bc..a19c2e66f82 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -59,8 +59,10 @@ export function createCodexJournalTranslator( (threadId, turnId) => genericFrames.suppress(threadId, turnId) ) const settleOversizedNotification = createCodexOversizedNotificationSettler(deps, items) - const prompts = new CodexJournalPrompts(deps, (threadId, itemId) => - items.detailFor(threadId, itemId) + const prompts = new CodexJournalPrompts( + deps, + (threadId, itemId) => items.detailFor(threadId, itemId), + (threadId) => activeTurns.current(threadId) ) const subagents = new CodexSubagentRoster({ sink: deps.sink, @@ -82,6 +84,8 @@ export function createCodexJournalTranslator( primaryThreadId: () => deps.primaryThreadId?.() ?? null, activeTurns, items, + pendingPrompts: prompts.pending, + ...(deps.clearPromptTurn ? { clearPromptTurn: deps.clearPromptTurn } : {}), flushSuppression: () => genericFrames.flush(), resetActivity, ...(deps.now ? { now: deps.now } : {}) diff --git a/src/main/codex/codex-structured-prompt-replies.test.ts b/src/main/codex/codex-structured-prompt-replies.test.ts index 831124c1c48..49626ebd0a1 100644 --- a/src/main/codex/codex-structured-prompt-replies.test.ts +++ b/src/main/codex/codex-structured-prompt-replies.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { applyCodexPromptAnswer, CodexPromptRegistry, + MAX_CODEX_PROMPT_REGISTRY_BYTES, MAX_CODEX_PROMPT_REGISTRY_ENTRIES, codexJournalPromptIdPart, decodeCodexQuestionOptionId, @@ -94,6 +95,49 @@ describe('CodexPromptRegistry', () => { expect(registry.find('codex-item-1')).toBeNull() }) + it('clears only prompts belonging to a settled turn', () => { + const registry = new CodexPromptRegistry() + registry.register({ + id: 1, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'root-item', threadId: 'thread-1' } + }) + registry.register({ + id: 2, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'other-item', threadId: 'thread-1', turnId: 'turn-2' } + }) + registry.register({ + id: 3, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'other-thread-item', threadId: 'thread-2', turnId: 'turn-1' } + }) + registry.bindJournalItemId('journal-root', 'thread-1', 'root-item', 'turn-1') + + registry.clearTurn('thread-1', 'turn-1') + + expect(registry.find('root-item')).toBeNull() + expect(registry.find('journal-root')).toBeNull() + expect(registry.find('other-item')?.requestId).toBe(2) + expect(registry.find('other-thread-item')?.requestId).toBe(3) + }) + + it('bounds an oversized backfilled turn id and still clears its prompt', () => { + const registry = new CodexPromptRegistry() + const turnId = 'turn-'.padEnd(MAX_CODEX_PROMPT_REGISTRY_BYTES + 1, 'x') + registry.register({ + id: 1, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'root-item', threadId: 'thread-1' } + }) + + registry.bindJournalItemId('journal-root', 'thread-1', 'root-item', turnId) + + expect(registry.bytes).toBeLessThanOrEqual(MAX_CODEX_PROMPT_REGISTRY_BYTES) + registry.clearTurn('thread-1', turnId) + expect(registry.find('journal-root')).toBeNull() + }) + it('addresses a prompt by its journal item id once bound, and forgets both', () => { const registry = new CodexPromptRegistry() const prompt = registry.register(userInputRequest(['q1'])) diff --git a/src/main/codex/codex-structured-prompt-replies.ts b/src/main/codex/codex-structured-prompt-replies.ts index 1c96a95c5f2..9f30bfe1a8d 100644 --- a/src/main/codex/codex-structured-prompt-replies.ts +++ b/src/main/codex/codex-structured-prompt-replies.ts @@ -4,6 +4,9 @@ import { MAX_CODEX_PROMPT_JOURNAL_BINDINGS, MAX_CODEX_PROMPT_REGISTRY_BYTES, MAX_CODEX_PROMPT_REGISTRY_ENTRIES, + codexPromptMatchesTurn, + codexPromptRegistryEntryBytes, + codexPromptTurnIdentity, codexJournalPromptIdPart, readQuestionIds, readQuestionOptionAnswers @@ -35,6 +38,8 @@ export type CodexPendingPrompt = { method: string threadId: string turnId: string | null + /** Oversized compatibility turn ids stay comparable without escaping the registry byte cap. */ + turnIdDigest?: string codexItemId: string /** What addresses this prompt. One tool item can ask more than once — a shell * bridge re-asks per command under the same `itemId` — so the request's own @@ -109,25 +114,7 @@ export class CodexPromptRegistry { } private promptBytes(prompt: CodexPendingPrompt): number { - let bytes = 0 - for (const value of [ - prompt.threadId, - prompt.turnId ?? '', - prompt.codexItemId, - prompt.promptKey - ]) { - bytes += Buffer.byteLength(value, 'utf8') - } - for (const id of prompt.questionIds) { - bytes += Buffer.byteLength(id, 'utf8') - } - for (const entry of prompt.optionAnswers.values()) { - bytes += Buffer.byteLength(entry.questionId, 'utf8') + Buffer.byteLength(entry.answer, 'utf8') - } - for (const value of prompt.answers.values()) { - bytes += Buffer.byteLength(value, 'utf8') - } - return bytes + return codexPromptRegistryEntryBytes(prompt) } private retainedPromptBytes(): number { @@ -222,7 +209,12 @@ export class CodexPromptRegistry { } /** Called by the translation module once the prompt has a journal id. */ - bindJournalItemId(journalItemId: string, threadId: string, promptKey: string): void { + bindJournalItemId( + journalItemId: string, + threadId: string, + promptKey: string, + turnId?: string | null + ): void { const existing = this.journalItemIds.get(journalItemId) if (existing) { this.boundPrompts.delete(journalItemId) @@ -233,6 +225,9 @@ export class CodexPromptRegistry { if (!prompt) { return } + if (prompt.turnId === null && prompt.turnIdDigest === undefined && turnId) { + Object.assign(prompt, codexPromptTurnIdentity(turnId)) + } this.journalItemIds.set(journalItemId, address) this.boundPrompts.set(journalItemId, prompt) this.trim() @@ -264,6 +259,18 @@ export class CodexPromptRegistry { } } + /** Drops requests that belonged to a turn which the provider has settled. */ + clearTurn(threadId: string, turnId: string): void { + const prompts = new Set( + [...this.byAddress.values(), ...this.boundPrompts.values()].filter( + (prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId) + ) + ) + for (const prompt of prompts) { + this.forget(prompt) + } + } + clear(): void { this.byAddress.clear() this.journalItemIds.clear() diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index b5cdd2caf4e..b104c559af2 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -88,8 +88,9 @@ export async function acquireCodexStructuredSession(input: { ...(deps.now ? { now: deps.now } : {}), primaryThreadId: () => primaryThreadId, subagentExecutions, - bindPromptItemId: (journalItemId, threadId, promptKey) => - acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey) + bindPromptItemId: (journalItemId, threadId, promptKey, turnId) => + acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey, turnId), + clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId) }) : null const open = deps.openConnection ?? openCodexAppServerConnection diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 061626f9724..d47bd81fc8e 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -179,10 +179,20 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessionId ) => this.sessions.get(sessionId)?.backgroundTasks.state - bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void => + bindPromptItemId = ( + sessionId: string, + journalItemId: string, + promptKey: string, + turnId?: string | null + ): void => this.sessions .get(sessionId) - ?.prompts.bindJournalItemId(journalItemId, this.session(sessionId).threadId, promptKey) + ?.prompts.bindJournalItemId( + journalItemId, + this.session(sessionId).threadId, + promptKey, + turnId + ) async dispatch(input: { sessionId: string diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx new file mode 100644 index 00000000000..b3321a6c86f --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx @@ -0,0 +1,29 @@ +// @vitest-environment happy-dom + +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { NativeChatApprovalCard } from './NativeChatApprovalCard' + +describe('NativeChatApprovalCard', () => { + it('exposes cancellation while it owns the composer region', () => { + const onCancel = vi.fn() + + render( + {}} + onCancel={onCancel} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onCancel).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index 9b394ada907..3c5766b7f2f 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -1,11 +1,14 @@ -import { ShieldQuestion } from 'lucide-react' +import { ShieldQuestion, X } from 'lucide-react' import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' import type { ChatApproval } from './native-chat-interactive-prompt' export type NativeChatApprovalCardProps = { approval: ChatApproval /** Send the chosen option's literal string to the agent's PTY. */ onChoose: (send: string) => void + /** Cancel the active provider turn while this card owns the composer region. */ + onCancel?: () => void } /** @@ -16,7 +19,8 @@ export type NativeChatApprovalCardProps = { */ export function NativeChatApprovalCard({ approval, - onChoose + onChoose, + onCancel }: NativeChatApprovalCardProps): React.JSX.Element { return (
@@ -24,7 +28,7 @@ export function NativeChatApprovalCard({
-
+

{approval.title}

{approval.detail ? (

@@ -32,6 +36,16 @@ export function NativeChatApprovalCard({

) : null}
+ {onCancel ? ( + + ) : null}
{approval.options.map((opt, i) => ( diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 3da6283057a..26b78787eca 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -53,6 +53,7 @@ export function NativeChatMessageList({ settledTurns, failedDeliveryMessageIds, showTurnStatus = true, + showLiveTurnActivity = true, turnActivity, runtimeContext }: { @@ -71,6 +72,8 @@ export function NativeChatMessageList({ failedDeliveryMessageIds?: ReadonlySet /** Turn timing and disclosure are available on structured agent sessions. */ showTurnStatus?: boolean + /** Whether the active turn's foreground activity row should be visible. */ + showLiveTurnActivity?: boolean turnActivity?: NativeChatTurnActivity | null runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { @@ -285,7 +288,7 @@ export function NativeChatMessageList({ context={rowContext} window={transcriptWindow} /> - {showTurnStatus && isWorking ? ( + {showTurnStatus && showLiveTurnActivity && isWorking ? ( { expect(spinner).toHaveClass('animate-spin', 'motion-reduce:animate-none') }) + it('hides foreground turn activity without settling live tool state', () => { + const { container } = render( + + ) + + expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull() + expect(screen.queryByText(/Working for/)).toBeNull() + expect(screen.queryByText('Thinking')).toBeNull() + expect(screen.getByText('Running pnpm test')).toHaveClass('animate-pulse') + }) + it('keeps the live row up after a tool settles', () => { render( void + showTurnStatus?: boolean + showLiveTurnActivity?: boolean + isWorking?: boolean + runtimeContext?: unknown +} + +const initialMessageListProps: StructuredSessionMessageListProps | null = null +const initialApprovalCardProps: NativeChatApprovalCardProps | null = null + /** * Shared mock state and `vi.mock` factories for the NativeChatStructuredSession test files. * Load it through `await vi.hoisted(async () => (await import(...)).createStructuredSessionMocks())` @@ -20,20 +33,17 @@ export function createStructuredSessionMocks() { mode: 'static' as 'static' | 'outbox', status: 'ready' as 'idle' | 'loading' | 'ready' | 'error', messages: null as null | unknown[], - messageListProps: null as null | { - allowFileUriLinks?: boolean - onLinkClick?: (...args: unknown[]) => void - showTurnStatus?: boolean - runtimeContext?: unknown - }, + messageListProps: initialMessageListProps, composerProps: null as null | { launchSeed?: NativeChatLaunchSeed structuredTransport?: Record isWorking?: boolean }, + approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, promptItems: [] as AgentJournalRenderItem[], respond: vi.fn() as StructuredSessionSpy, + cancel: vi.fn() as StructuredSessionSpy, handlePasteEvent: vi.fn() as StructuredSessionSpy, pasteFromClipboard: vi.fn() as StructuredSessionSpy, submissions: [] as unknown[], @@ -105,7 +115,7 @@ export function createStructuredSessionMocks() { supportsStopAll: mocks.supportsBackgroundTaskStopAll }, turnId: mocks.turnId, - cancel: vi.fn() as StructuredSessionSpy, + cancel: mocks.cancel, stopBackgroundTask: (taskId?: string) => mocks.stopBackgroundTask(props.sessionId, taskId), respond: mocks.respond, @@ -171,7 +181,12 @@ export function createStructuredSessionMocks() { }) }), nativeChatEmptyState: () => ({ NativeChatEmptyState: () => null }), - nativeChatApprovalCard: () => ({ NativeChatApprovalCard: () => null }), + nativeChatApprovalCard: () => ({ + NativeChatApprovalCard: (props: NativeChatApprovalCardProps) => { + mocks.approvalCardProps = props + return null + } + }), nativeChatQuestionCard: () => ({ NativeChatQuestionCard: (props: NativeChatQuestionCardProps) => { mocks.questionCardProps = props @@ -187,9 +202,11 @@ export function createStructuredSessionMocks() { mocks.messages = null mocks.messageListProps = null mocks.composerProps = null + mocks.approvalCardProps = null mocks.questionCardProps = null mocks.promptItems = [] mocks.respond.mockReset() + mocks.cancel.mockReset() mocks.handlePasteEvent.mockReset() mocks.pasteFromClipboard.mockReset() mocks.submissions = [] diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index f4432620ae3..847e50ace06 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { decodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { useAppStore } from '@/store' import { claudeGroupedQuestionPromptItems, @@ -154,6 +155,104 @@ describe('NativeChatStructuredSession', () => { } ) + it('suppresses live turn activity for a pending question without ending the turn', () => { + mocks.isWorking = true + mocks.turnId = 'turn-question' + mocks.promptItems = legacySingleQuestionPromptItems + const view = () => ( + + ) + const { rerender } = render(view()) + + expect(mocks.messageListProps).toMatchObject({ + isWorking: true, + showLiveTurnActivity: false + }) + expect( + document + .querySelector('[data-native-chat-root="true"]') + ?.getAttribute('data-native-chat-working') + ).toBe('true') + expect(mocks.questionCardProps).not.toBeNull() + expect(screen.queryByTestId('structured-composer')).toBeNull() + + act(() => mocks.questionCardProps?.onCancel()) + expect(mocks.cancel).toHaveBeenCalledWith('turn-question') + expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) + + mocks.promptItems = [] + rerender(view()) + expect(mocks.messageListProps).toMatchObject({ + isWorking: true, + showLiveTurnActivity: true + }) + expect(screen.getByTestId('structured-composer')).toBeTruthy() + expect(mocks.composerProps?.isWorking).toBe(true) + }) + + it('suppresses live turn activity for a pending approval but keeps background work visible', () => { + const approvalItems: AgentJournalRenderItem[] = [ + { + itemId: 'approval-item', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Allow command?', + detail: 'pnpm test', + options: [ + { id: 'allow', label: 'Allow' }, + { id: 'deny', label: 'Deny' } + ], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + mocks.isWorking = true + mocks.turnId = 'turn-approval' + mocks.promptItems = approvalItems + mocks.monitoringBackgroundTasks = true + + render( + + ) + + expect(mocks.messageListProps).toMatchObject({ + isWorking: true, + showLiveTurnActivity: false + }) + expect(mocks.approvalCardProps?.approval.title).toBe('Allow command?') + expect(screen.queryByTestId('structured-composer')).toBeNull() + expect(document.querySelector('[data-native-chat-background-tasks="true"]')).not.toBeNull() + + act(() => mocks.approvalCardProps?.onChoose('allow')) + expect(mocks.respond).toHaveBeenCalledWith(approvalItems[0], 'allow') + expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) + + act(() => mocks.approvalCardProps?.onCancel?.()) + expect(mocks.cancel).toHaveBeenCalledWith('turn-approval') + }) + // Every background-task test mounts the same local Claude session; only the ids // differ. A fresh element per call also matters for the rerenders below: React // bails out of re-rendering an identical one. diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 9e75556ccbc..867208240a8 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -227,6 +227,7 @@ export function NativeChatStructuredSession( workingStartedAt={controller.workingStartedAt} settledTurns={controller.settledTurns} showTurnStatus + showLiveTurnActivity={prompt === null} turnActivity={controller.turnActivity} onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} @@ -245,6 +246,11 @@ export function NativeChatStructuredSession( })) }} onChoose={(optionId) => void controller.respond(prompt, optionId)} + onCancel={() => { + if (controller.turnId) { + void controller.cancel(controller.turnId) + } + }} /> ) : null} {prompt && questionBody ? ( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx new file mode 100644 index 00000000000..91769bde531 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { useStructuredAgentSession } from './use-structured-agent-session' + +const items: AgentJournalRenderItem[] = [ + { + itemId: 'turn-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } + }, + { + itemId: 'question-1', + revision: 1, + sequence: 2, + observedAt: 2, + body: { + kind: 'question', + question: 'Which approach?', + options: [{ id: 'focused', label: 'Focused' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } +] + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn().mockResolvedValue(null) +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items, + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +vi.mock('./use-structured-agent-session-outbox', () => ({ + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + blockedClientMessageId: null, + error: null, + send: vi.fn(), + retry: vi.fn() + }) +})) + +it('keeps a prompted provider turn working and cancellable beneath presentation policy', () => { + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true + }) + ) + + expect(result.current.isWorking).toBe(true) + expect(result.current.turnId).toBe('provider-turn') + expect(result.current.prompts).toHaveLength(1) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index f95718a0e79..be20364c1dc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -120,7 +120,6 @@ describe('useStructuredAgentSession working state', () => { beforeEach(() => { vi.clearAllMocks() fence = 3 - items = [] submissions = [] mocks.call.mockResolvedValue(null) }) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a1c33b790bc..8707f0d0adb 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2610,6 +2610,9 @@ }, "components": { "native-chat": { + "approval": { + "cancel": "Cancel" + }, "composer": { "effort": "Effort" }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 81700b22ff3..aecae8bc23d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17241,7 +17241,8 @@ "approval": { "title": "Allow {{value0}}?", "allow": "Allow", - "deny": "Deny" + "deny": "Deny", + "cancel": "Cancel" }, "launchPromptNotDelivered": "Not delivered — check the terminal", "structuredSessionCloseFailed": "Could not close this chat session", From 875b86d1688f39ad09b6048c374682dc2a53d4e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:30:25 +0000 Subject: [PATCH 04/43] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 7a0586b8b1e..bd7a18f8488 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 53m + + downloads: 54m @@ -15,7 +15,7 @@ downloads downloads - 53m - 53m + 54m + 54m From 1d1bca2a7bc7b768d31b5163967dd5058e34c245 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:11:17 -0700 Subject: [PATCH 05/43] Bump mobile app.json to 0.0.50 (#20661) --- mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/app.json b/mobile/app.json index 6121923f775..cfe66109e22 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.48", + "version": "0.0.50", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", From b4d435806f5c6543a435923ea8dc8ea5ec4d76bc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:14:07 -0700 Subject: [PATCH 06/43] fix(native-chat): bound a dispatch reason before it reaches the journal row (#20654) `AgentJournalSubmission.reason` was the only unbounded field written by Orca's own code. `dispatchSafely` sets it from the adapter's raw `error.message` and `journalDispatchRowBuilder` stored it verbatim, so a provider error carrying a multi-megabyte body -- a stringified HTTP error payload, say -- reached the row at whatever length the provider sent, and stayed on disk at that size for the life of the journal. It now goes through `boundInlineText` with the journal's existing inline limit, the same idiom already applied to arbitrary text on the Claude and Codex translation paths. The bound must stay head-preserving. `dispatchRejectionWasTransportWriteFailure` prefix-matches the value, and `dispatchRejectionReasonIsInternal` builds on it, so a bound that kept the tail instead would stop classifying a clipped transport failure and render raw provider text to the user as an ordinary rejection notice. A test pins that, and clipping stays marked rather than silent so a truncated reason is never presented as the provider's complete explanation. Rows written before this keep their full text, so readers can still meet an unbounded reason. --- .../journal-dispatch-reason-bound.test.ts | 87 +++++++++++++++++++ .../journal-row-builders.ts | 15 +++- 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts new file mode 100644 index 00000000000..70052865242 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { + DISPATCH_REJECTED_WRITE_FAILED, + dispatchRejectionReasonIsInternal, + dispatchRejectionWasTransportWriteFailure +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import type { openAgentSessionJournal } from './journal-store-factory' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const HUGE = 'x'.repeat(4 * DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes) + +let root: string +let clock = 1_000 + +const journals = createTrackedJournalOpener() + +async function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: () => (clock += 1), + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +async function settle(reason: string): Promise { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'msg-1', + payloadFingerprint: 'e'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + await journal.resolveDispatch({ clientMessageId: 'msg-1', state: 'rejected', reason, fence: 1 }) + return journal.snapshot().submissions[0]?.reason ?? null +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-dispatch-reason-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('dispatch reason bounding', () => { + it('bounds an oversized provider error before it reaches the row', async () => { + const stored = await settle(HUGE) + expect(stored).not.toBeNull() + expect(stored?.length).toBeLessThan(HUGE.length) + }) + + it('marks the clipped reason rather than truncating it silently', async () => { + const stored = await settle(HUGE) + expect(stored).toContain('[Orca: output truncated') + }) + + it('leaves a reason that already fits exactly as written', async () => { + const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe`) + expect(stored).toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe`) + }) + + // Head-first, not hash-replacing: the classifier prefix-matches, so a bound that kept + // the tail would render raw provider text to the user as an ordinary rejection notice. + it('keeps a clipped transport failure classifiable', async () => { + const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) + expect(stored).not.toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) + expect(dispatchRejectionWasTransportWriteFailure(stored)).toBe(true) + expect(dispatchRejectionReasonIsInternal(stored)).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index be2c2552775..e5e376940fe 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -20,6 +20,7 @@ import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES, MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS } from './journal-row-schema' +import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import type { ResolveDispatchInput } from './journal-store-contracts' type RowBuilder = (seq: number, ts: number) => T @@ -76,8 +77,7 @@ export function journalDispatchRowBuilder( clientMessageId: input.clientMessageId, dispatchState: input.state, providerItemId, - reason: - input.state === 'accepted' || input.state === 'pending' ? null : (input.reason ?? null), + reason: boundedDispatchReason(input), seq, fence: input.fence, ts, @@ -85,6 +85,17 @@ export function journalDispatchRowBuilder( }) } +/** `reason` is the only unbounded field written by Orca's own code: a provider error is + * arbitrary text, and a multi-megabyte one reached the row verbatim. Bounded head-first, + * because `dispatchRejectionWasTransportWriteFailure` prefix-matches the value. Rows + * written before this keep their full text, so readers still meet unbounded ones. */ +function boundedDispatchReason(input: ResolveDispatchInput): string | null { + if (input.state === 'accepted' || input.state === 'pending' || !input.reason) { + return null + } + return boundInlineText(input.reason, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text +} + export type JournalLifecycleMutationInput = | { kind: 'item'; identity: AgentJournalItemIdentity; body: AgentJournalItemBody } | { kind: 'tombstone'; identity: AgentJournalItemIdentity } From d8b6151e8c8fc00c8759f42db7db633617358439 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:22:44 -0700 Subject: [PATCH 07/43] fix(native-chat): keep a resumed transcript pinned to its end (#20651) * fix(native-chat): keep a resumed transcript pinned to its end Follow state was recomputed from distance on every scroll event, and a pin writes scrollTop itself, so the browser reports that write back as a scroll event a frame later. Once a resumed session's later history pages and settling row heights had moved the end away from it, that echoed event read as the reader leaving and the pin was dropped for good, stranding them mid-transcript. Measured in Chromium: a pin followed by same-task growth delivers a scroll event reading 2000px from the bottom, indistinguishable from a reader scrolling up. Pins now go through the virtualizer instead of writing scrollTop directly, so both parties resolve the end through the same maximum rather than holding rival definitions of it. Whether the reader left is now a question of provenance rather than distance: an offset this transcript wrote is never a departure. The end test reads live geometry, because the virtualizer's own isAtEnd subtracts a cached offset from a live maximum and this handler runs before that cache is refreshed. overflow-anchor:none stops the engine moving scrollTop under a settling row, which would otherwise look like the reader. This does not make ownership singular. The virtualizer still writes autonomously from several paths and those writes stay unattributed; what this removes is the rival definition of the end, not the second writer. * fix(native-chat): cancel stale end reconciliation * fix(native-chat): attribute scroll ownership centrally --- .../native-chat/NativeChatMessageList.tsx | 11 +- .../NativeChatMessageList.windowing.test.tsx | 57 +++++++- .../native-chat-autoscroll.test.ts | 26 ++++ .../native-chat/native-chat-autoscroll.ts | 18 +++ .../use-native-chat-transcript-scroll.ts | 134 +++++++++++------- ...ve-chat-transcript-window.options.test.tsx | 80 +++++++++-- .../use-native-chat-transcript-window.ts | 120 +++++++++++++++- 7 files changed, 375 insertions(+), 71 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 26b78787eca..108a188c4ab 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -209,7 +209,10 @@ export function NativeChatMessageList({ hasMore, loadingEarlier, loadEarlier, - alignToViewportTop: transcriptWindow.alignToViewportTop + alignToViewportTop: transcriptWindow.alignToViewportTop, + scrollToEnd: transcriptWindow.scrollToEnd, + consumeProgrammaticScroll: transcriptWindow.consumeProgrammaticScroll, + reconcileReaderScroll: transcriptWindow.reconcileReaderScroll }) const rowContext = useMemo( @@ -253,7 +256,11 @@ export function NativeChatMessageList({ // Named so measurement can find the scroll root without depending on // which utility class happens to make it scroll. data-native-chat-scroll - className="scrollbar-sleek relative h-full overflow-y-auto [scrollbar-gutter:stable_both-edges]" + // `overflow-anchor:none`: the transcript decides whether an offset + // it did not write is the reader moving, so the engine adjusting + // scrollTop under a settling row would read as a departure. The + // virtualizer does its own end anchoring, so this is redundant here. + className="scrollbar-sleek relative h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable_both-edges]" // Why: `zoom` scales the chat transcript's text and layout together, // scoped to this pane so the rest of the app is untouched. It sits on // the scroll container rather than the content inside it so that diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx index c4de79ceeed..8d9f3ffa83b 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx @@ -30,6 +30,7 @@ const TRANSCRIPT_LENGTH = 200 * a pin computed from the virtualizer's totals and one computed from the * document disagree. */ const BELOW_TRANSCRIPT_PX = 24 +let belowTranscriptPx = BELOW_TRANSCRIPT_PX /** Heights the stubbed layout reports per row index, when a case wants a row to * measure as something other than its estimate. Empty means "every row at its @@ -109,7 +110,7 @@ function stubLayout({ // The transcript column: as tall as the window it wraps, plus what sits // under it. This is the element the list observes for streamed growth. return this.classList.contains('max-w-4xl') - ? reservedTranscriptHeight(this) + BELOW_TRANSCRIPT_PX + ? reservedTranscriptHeight(this) + belowTranscriptPx : 0 } }) @@ -124,7 +125,7 @@ function stubLayout({ overrideLayoutProperty('scrollHeight', { get(this: HTMLElement): number { return this.hasAttribute('data-native-chat-scroll') - ? reservedTranscriptHeight(this) + BELOW_TRANSCRIPT_PX + ? reservedTranscriptHeight(this) + belowTranscriptPx : 0 } }), @@ -566,12 +567,15 @@ describe('a row growing in place while the view is pinned to the bottom', () => beforeEach(() => { restoreLayout = stubLayout({ scrollGeometry: true }) restoreResizeObserver = stubResizeObserver() + belowTranscriptPx = BELOW_TRANSCRIPT_PX setMeasuredTail(0) }) afterEach(() => { restoreResizeObserver() restoreLayout() measuredRowHeights = [] + belowTranscriptPx = BELOW_TRANSCRIPT_PX + vi.restoreAllMocks() }) it('holds the pin, the mount and the reserved total at every frame of the growth', () => { @@ -637,4 +641,53 @@ describe('a row growing in place while the view is pinned to the bottom', () => expect(screen.getByRole('button', { name: /jump to latest/i })).toBeInTheDocument() }) + + it('keeps following when a pin echo arrives after the document grows', () => { + setMeasuredTail(0) + const { container } = render(streamingList(0)) + paint(container) + const scroller = scrollRoot(container) + + setMeasuredTail(1) + expect(deliverResizes()).toBe(true) + const pinnedAt = scroller.scrollTop + belowTranscriptPx += 2_000 + + fireEvent.scroll(scroller) + + expect(scroller.scrollTop).toBe(pinnedAt) + expect(screen.queryByRole('button', { name: /jump to latest/i })).toBeNull() + }) + + it('settles a pending end reconcile after the reader keeps scrolling away', async () => { + setMeasuredTail(0) + const { container } = render(streamingList(0)) + const scroller = scrollRoot(container) + // Trigger a pin outside React's act wrapper so its TanStack rAF reconcile is + // still pending when the reader moves away. + setMeasuredTail(1) + expect(deliverResizes()).toBe(true) + const scheduleSpy = vi.spyOn(window, 'requestAnimationFrame') + const scrollToSpy = vi.spyOn(scroller, 'scrollTo') + const readingAt = 2000 + scroller.scrollTop = readingAt + fireEvent.scroll(scroller) + expect(scrollToSpy).toHaveBeenLastCalledWith({ behavior: 'auto', top: readingAt }) + scroller.scrollTop = 1800 + fireEvent.scroll(scroller) + expect(scrollToSpy).toHaveBeenLastCalledWith({ behavior: 'auto', top: 1800 }) + + await act(async () => { + for (let frame = 0; frame < 6; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + } + }) + + const scheduledFrames = scheduleSpy.mock.calls.length + scheduleSpy.mockRestore() + scrollToSpy.mockRestore() + expect(scheduledFrames).toBeLessThanOrEqual(8) + expect(scroller.scrollTop).toBe(1800) + expect(screen.getByRole('button', { name: /jump to latest/i })).toBeInTheDocument() + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts b/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts index f7ae35bf3ea..fa23669e931 100644 --- a/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { distanceFromBottom, isNearBottom, + nextFollowingEnd, shouldLoadEarlier, shouldShowJumpToLatest, NATIVE_CHAT_BOTTOM_THRESHOLD_PX @@ -44,6 +45,31 @@ describe('shouldShowJumpToLatest', () => { }) }) +// The browser reports application writes as ordinary scroll events. Explicit +// marks distinguish their delayed echoes from reader movement after growth. +describe('nextFollowingEnd', () => { + const following = { following: true, programmatic: false, atEnd: true } + + it('follows when the reader reaches the end', () => { + expect(nextFollowingEnd(following)).toBe(true) + }) + + // The resume bug: history pages in and rows settle their measured heights, so + // the end runs away from an offset the transcript itself pinned. That is not a + // reader leaving, and treating it as one strands them mid-transcript. + it('keeps following when a delayed application scroll arrives after growth', () => { + expect(nextFollowingEnd({ ...following, programmatic: true, atEnd: false })).toBe(true) + }) + + it('treats an unmarked offset away from the end as the reader leaving', () => { + expect(nextFollowingEnd({ ...following, atEnd: false })).toBe(false) + }) + + it('does not re-attach a detached reader from an application write', () => { + expect(nextFollowingEnd({ following: false, programmatic: true, atEnd: false })).toBe(false) + }) +}) + // Windowing turns measurement into a constant source of movement: every row that // resolves its real height changes the content and re-fires the observers that // ask this question. So "near the top" alone can no longer be the answer. diff --git a/src/renderer/src/components/native-chat/native-chat-autoscroll.ts b/src/renderer/src/components/native-chat/native-chat-autoscroll.ts index 6f6dc8b79d2..f07aeeb6671 100644 --- a/src/renderer/src/components/native-chat/native-chat-autoscroll.ts +++ b/src/renderer/src/components/native-chat/native-chat-autoscroll.ts @@ -43,6 +43,24 @@ export function shouldShowJumpToLatest( return distanceFromBottom(geometry) > threshold } +export type FollowIntent = { + following: boolean + /** Whether the scroll event matches an offset the application registered. */ + programmatic: boolean + atEnd: boolean +} + +/** Whether the transcript should still follow the end after this offset. + * + * Application writes preserve intent even when their delayed events arrive + * after the end moved. Reader events detach away from the end and reattach at it. */ +export function nextFollowingEnd(intent: FollowIntent): boolean { + if (intent.programmatic) { + return intent.following + } + return intent.atEnd +} + /** Distance from the top within which the transcript pages in older history. */ export const NATIVE_CHAT_LOAD_EARLIER_THRESHOLD_PX = 80 diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts index 7c9549a1f13..0f690bce57e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts @@ -6,10 +6,23 @@ // about, not what they decide: rows resolving their measured height move the // content constantly, so "the content changed" and "the reader scrolled" stopped // being the same event and only the latter may ask for another page. +// +// The offset belongs to the virtualizer — every pin goes through it, so a scroll +// it is still reconciling is replaced rather than raced. Its public write adapter +// marks every application offset; follow intent changes only on an unmarked +// reader event, never from delayed geometry alone. -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type UIEventHandler +} from 'react' import { isNearBottom, + nextFollowingEnd, shouldLoadEarlier, shouldShowJumpToLatest, type ScrollGeometry @@ -25,7 +38,7 @@ function geometryOf(element: HTMLElement): ScrollGeometry { export type NativeChatTranscriptScroll = { showJump: boolean - onScroll: () => void + onScroll: UIEventHandler scrollToBottom: () => void /** Align an element inside the transcript with the top of the viewport. */ scrollMessageToTop: (element: HTMLElement) => void @@ -40,7 +53,10 @@ export function useNativeChatTranscriptScroll({ hasMore, loadingEarlier, loadEarlier, - alignToViewportTop + alignToViewportTop, + scrollToEnd, + consumeProgrammaticScroll, + reconcileReaderScroll }: { scrollRef: React.RefObject contentRef: React.RefObject @@ -51,74 +67,88 @@ export function useNativeChatTranscriptScroll({ loadingEarlier: boolean loadEarlier: () => void alignToViewportTop: (element: HTMLElement) => void + scrollToEnd: () => void + consumeProgrammaticScroll: (event: Event) => boolean + reconcileReaderScroll: (isTakingOver: boolean) => void }): NativeChatTranscriptScroll { const [showJump, setShowJump] = useState(false) - const stuckToBottomRef = useRef(true) + const followingRef = useRef(true) const previousScrollTopRef = useRef(0) const loadEarlierRequestedAtRef = useRef(null) - const syncScrollState = useCallback((): ScrollGeometry | null => { - const element = scrollRef.current - if (!element) { - return null - } - const geometry = geometryOf(element) - const stick = isNearBottom(geometry) - stuckToBottomRef.current = stick - setShowJump(shouldShowJumpToLatest(stick, geometry)) - return geometry - }, [scrollRef]) + const syncScrollState = useCallback( + (event?: Event): ScrollGeometry | null => { + const element = scrollRef.current + if (!element) { + return null + } + const geometry = geometryOf(element) + if (event) { + const wasFollowing = followingRef.current + const programmatic = consumeProgrammaticScroll(event) + const following = nextFollowingEnd({ + following: followingRef.current, + programmatic, + atEnd: isNearBottom(geometry) + }) + followingRef.current = following + if (!programmatic) { + reconcileReaderScroll(wasFollowing && !following) + } + } + setShowJump(shouldShowJumpToLatest(followingRef.current, geometry)) + return geometry + }, + [consumeProgrammaticScroll, reconcileReaderScroll, scrollRef] + ) // Only a real scroll event pages in older history. Every row that resolves its // true height moves the content and re-fires the size observers; routing those // through here too would ask for the next page once per measurement. - const onScroll = useCallback(() => { - const geometry = syncScrollState() - if (!geometry) { - return - } - const previousScrollTop = previousScrollTopRef.current - previousScrollTopRef.current = geometry.scrollTop - if ( - shouldLoadEarlier({ - geometry, - previousScrollTop, - hasMore, - loadingEarlier, - itemCount, - requestedAtItemCount: loadEarlierRequestedAtRef.current - }) - ) { - loadEarlierRequestedAtRef.current = itemCount - loadEarlier() - } - }, [hasMore, itemCount, loadEarlier, loadingEarlier, syncScrollState]) + const onScroll = useCallback>( + (event) => { + const geometry = syncScrollState(event.nativeEvent) + if (!geometry) { + return + } + const previousScrollTop = previousScrollTopRef.current + previousScrollTopRef.current = geometry.scrollTop + if ( + shouldLoadEarlier({ + geometry, + previousScrollTop, + hasMore, + loadingEarlier, + itemCount, + requestedAtItemCount: loadEarlierRequestedAtRef.current + }) + ) { + loadEarlierRequestedAtRef.current = itemCount + loadEarlier() + } + }, + [hasMore, itemCount, loadEarlier, loadingEarlier, syncScrollState] + ) const scrollToBottom = useCallback(() => { - const element = scrollRef.current - if (!element) { - return - } - // The document's own bottom, not the window's last row: the typing indicator, - // the activity line and the column's end padding all live past it. - element.scrollTop = element.scrollHeight - stuckToBottomRef.current = true + followingRef.current = true + scrollToEnd() setShowJump(false) - }, [scrollRef]) + }, [scrollToEnd]) const scrollMessageToTop = useCallback( (element: HTMLElement) => { - stuckToBottomRef.current = false + followingRef.current = false alignToViewportTop(element) }, [alignToViewportTop] ) useLayoutEffect(() => { - if (stuckToBottomRef.current) { - scrollToBottom() + if (followingRef.current) { + scrollToEnd() } - }, [itemCount, isWorking, showTypingIndicator, scrollToBottom]) + }, [itemCount, isWorking, showTypingIndicator, scrollToEnd]) useEffect(() => { const element = scrollRef.current @@ -126,8 +156,8 @@ export function useNativeChatTranscriptScroll({ return } const observer = new ResizeObserver(() => { - if (stuckToBottomRef.current) { - scrollToBottom() + if (followingRef.current) { + scrollToEnd() } else { syncScrollState() } @@ -139,7 +169,7 @@ export function useNativeChatTranscriptScroll({ observer.observe(contentRef.current) } return () => observer.disconnect() - }, [contentRef, scrollRef, scrollToBottom, syncScrollState]) + }, [contentRef, scrollRef, scrollToEnd, syncScrollState]) return { showJump, onScroll, scrollToBottom, scrollMessageToTop } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx index 0a71ac8e905..da19a51a9c3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx @@ -14,21 +14,27 @@ type VirtualizerOptionsCapture = { | null } -const virtualizerMock = vi.hoisted(() => ({ - options: { current: null } as VirtualizerOptionsCapture, - getTotalSize: vi.fn(() => 0), - getVirtualItems: vi.fn(() => []), - measureElement: vi.fn(), - measure: vi.fn(), - resizeItem: vi.fn(), - scrollToOffset: vi.fn(), - takeSnapshot: vi.fn<() => VirtualItem[]>(() => []) -})) +const virtualizerMock = vi.hoisted(() => { + const scrollElement: { current: HTMLElement | null } = { current: null } + return { + options: { current: null } as VirtualizerOptionsCapture, + getTotalSize: vi.fn(() => 0), + getVirtualItems: vi.fn(() => []), + measureElement: vi.fn(), + measure: vi.fn(), + resizeItem: vi.fn(), + scrollElement, + scrollToEnd: vi.fn(), + scrollToOffset: vi.fn(), + takeSnapshot: vi.fn<() => VirtualItem[]>(() => []) + } +}) vi.mock('@tanstack/react-virtual', () => ({ + elementScroll: vi.fn(), useVirtualizer: (options: VirtualizerOptionsCapture['current']) => { virtualizerMock.options.current = options - return { ...virtualizerMock, scrollElement: null } + return { ...virtualizerMock, scrollElement: virtualizerMock.scrollElement.current } } })) @@ -56,6 +62,7 @@ function slot(id: string): NativeChatTranscriptSlot { afterEach(() => { cleanup() virtualizerMock.options.current = null + virtualizerMock.scrollElement.current = null vi.clearAllMocks() }) @@ -118,4 +125,55 @@ describe('native chat transcript virtualizer contract', () => { expect(virtualizerMock.options.current?.getItemKey).toBe(getItemKey) }) + + it('attributes a clamped fallback landing after content grows before its echo', () => { + const scrollElement = document.createElement('div') + let scrollHeight = 1_000 + let scrollTop = 0 + Object.defineProperties(scrollElement, { + clientHeight: { configurable: true, get: () => 100 }, + scrollHeight: { configurable: true, get: () => scrollHeight }, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = Math.max(0, Math.min(value, scrollHeight - 100)) + } + } + }) + const { result } = renderHook(() => + useNativeChatTranscriptWindow({ + scrollRef: { current: scrollElement }, + slots: [slot('message-0')], + revealIndex: -1 + }) + ) + + result.current.scrollToEnd() + expect(scrollTop).toBe(900) + scrollHeight = 1_400 + + expect(result.current.consumeProgrammaticScroll(new Event('scroll'))).toBe(true) + }) + + it('lets an explicit reveal supersede a pending reader takeover', () => { + const scrollElement = document.createElement('div') + const target = document.createElement('div') + scrollElement.append(target) + virtualizerMock.scrollElement.current = scrollElement + const { result } = renderHook(() => + useNativeChatTranscriptWindow({ + scrollRef: { current: scrollElement }, + slots: [slot('message-0')], + revealIndex: -1 + }) + ) + + result.current.reconcileReaderScroll(true) + result.current.alignToViewportTop(target) + virtualizerMock.scrollToOffset.mockClear() + result.current.reconcileReaderScroll(false) + + expect(virtualizerMock.scrollToOffset).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts index 0dacb9319b0..24b63e1fbae 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts @@ -13,8 +13,9 @@ // not: mixing the two puts the window out of place by exactly the zoom factor. // One path does read rects, and it converts them back before using them. -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useVirtualizer, type VirtualItem } from '@tanstack/react-virtual' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { elementScroll, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual' +import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll' import { NATIVE_CHAT_ROW_GAP_PX } from './native-chat-row-height-estimate' import { nativeChatPinnedRowIndexes, nativeChatTranscriptRange } from './native-chat-pinned-rows' @@ -38,6 +39,16 @@ export type NativeChatTranscriptWindow = { measureRow: (node: HTMLElement | null) => void /** Scroll so this element's top meets the top of the viewport. */ alignToViewportTop: (element: HTMLElement) => void + /** Pin to the transcript's end. Through the virtualizer for the same reason + * the reveal is: it owns the offset, and a write it does not recognise as its + * own is a reconcile it will fight. Its last-item `end` target is the + * browser's real max scroll, so this lands where the document bottom is, + * trailing chrome included. */ + scrollToEnd: () => void + /** True when this scroll event is the echo of a registered application write. */ + consumeProgrammaticScroll: (event: Event) => boolean + /** Rebase a pending end reconcile while the reader takes over this frame. */ + reconcileReaderScroll: (isTakingOver: boolean) => void } /** Distance from a container's scroll origin down to a descendant, in the @@ -86,6 +97,8 @@ export function useNativeChatTranscriptWindow({ }): NativeChatTranscriptWindow { const sizerElementRef = useRef(null) const [scrollMargin, setScrollMargin] = useState(0) + const [programmaticScrollMarks] = useState(createProgrammaticScrollMarks) + const readerTakeoverFrameRef = useRef(null) const previousMeasurementKeysRef = useRef | null>(null) const retiredMeasurementCountRef = useRef(0) const pinned = useMemo( @@ -123,9 +136,42 @@ export function useNativeChatTranscriptWindow({ scrollMargin, anchorTo: 'end', followOnAppend: true, - scrollEndThreshold: NATIVE_CHAT_BOTTOM_THRESHOLD_PX + scrollEndThreshold: NATIVE_CHAT_BOTTOM_THRESHOLD_PX, + // Every virtualizer write uses this public adapter, including measurement + // adjustments and prepend anchoring, so scroll events have one provenance. + scrollToFn: (offset, options, instance) => { + const target = offset + (options.adjustments ?? 0) + const element = instance.scrollElement + if (options.behavior === 'smooth') { + if (element) { + const max = Math.max(0, element.scrollHeight - element.clientHeight) + const landing = Math.max(0, Math.min(target, max)) + if (element.scrollTop !== landing) { + programmaticScrollMarks.mark(landing) + } + } + elementScroll(offset, options, instance) + return + } + const previous = element?.scrollTop + elementScroll(offset, options, instance) + // Scroll events dispatch later; read back now so a clamp against the old + // document height stays attributable if content grows before its echo. + const landing = element?.scrollTop + if (previous !== undefined && landing !== undefined && landing !== previous) { + programmaticScrollMarks.mark(landing) + } + } }) + const finishReaderTakeover = useCallback(() => { + if (readerTakeoverFrameRef.current !== null) { + window.cancelAnimationFrame(readerTakeoverFrameRef.current) + readerTakeoverFrameRef.current = null + } + }, []) + useEffect(() => finishReaderTakeover, [finishReaderTakeover]) + // Read, never assumed: the "load earlier" button sits above the window and // appears exactly when a prepend is about to land, which is the one moment a // stale margin would place every row wrong. @@ -208,14 +254,77 @@ export function useNativeChatTranscriptWindow({ } const top = nativeChatScrollOffsetWithin(element, container) ?? rectOffsetWithin(element, container) + finishReaderTakeover() // Through the virtualizer so a scroll it is still reconciling — the jump // that mounted this row in the first place — is replaced rather than raced. if (virtualizer.scrollElement) { virtualizer.scrollToOffset(top, { align: 'start', behavior: 'smooth' }) } else { + const max = Math.max(0, container.scrollHeight - container.clientHeight) + const landing = Math.max(0, Math.min(top, max)) + if (container.scrollTop !== landing) { + programmaticScrollMarks.mark(landing) + } container.scrollTo({ top, behavior: 'smooth' }) } }, + [finishReaderTakeover, programmaticScrollMarks, scrollRef, virtualizer] + ) + + const scrollToEnd = useCallback(() => { + const container = scrollRef.current + if (!container) { + return + } + finishReaderTakeover() + if (virtualizer.scrollElement) { + virtualizer.scrollToEnd({ behavior: 'auto' }) + return + } + // No virtualizer yet (a container without layout): the document's own bottom + // is the same offset the virtualizer would resolve for the last row. + const previous = container.scrollTop + container.scrollTop = container.scrollHeight + if (container.scrollTop !== previous) { + programmaticScrollMarks.mark(container.scrollTop) + } + }, [finishReaderTakeover, programmaticScrollMarks, scrollRef, virtualizer]) + + const consumeProgrammaticScroll = useCallback( + (event: Event): boolean => { + const container = scrollRef.current + if (!container) { + return false + } + return programmaticScrollMarks.consume( + event, + container.scrollTop, + Math.max(0, container.scrollHeight - container.clientHeight) + ) + }, + [programmaticScrollMarks, scrollRef] + ) + + const reconcileReaderScroll = useCallback( + (isTakingOver: boolean) => { + const container = scrollRef.current + if ( + !container || + !virtualizer.scrollElement || + (!isTakingOver && readerTakeoverFrameRef.current === null) + ) { + return + } + virtualizer.scrollToOffset(container.scrollTop, { behavior: 'auto' }) + if (readerTakeoverFrameRef.current !== null) { + return + } + // The public rebase itself reconciles on the next frame. Keep replacing its + // target until that frame so every reader move in the takeover wins. + readerTakeoverFrameRef.current = window.requestAnimationFrame(() => { + readerTakeoverFrameRef.current = null + }) + }, [scrollRef, virtualizer] ) @@ -225,6 +334,9 @@ export function useNativeChatTranscriptWindow({ scrollMargin, sizerRef, measureRow: virtualizer.measureElement, - alignToViewportTop + alignToViewportTop, + scrollToEnd, + consumeProgrammaticScroll, + reconcileReaderScroll } } From 2acd2f4c88f007e8c000a012bf305c79d0cee33a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:45:25 -0700 Subject: [PATCH 08/43] Surface stage, unstage and discard failures with retry capability (#20423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(source-control): surface stage, unstage and discard failures * fix(source-control): use single slot for entry failure toasts - Consolidate entry failures to one stable slot instead of per-worktree - Handle stale retries inline at click time rather than via a cleanup hook - Remove retry button from discard failures to prevent destructive accidents * test: improve type safety and mock patterns in source-control tests - Add proper type definitions for toast options and test data instead of using `as never` - Replace `mock.calls.at(-1)` with safer `mock.lastCall` pattern - Create `entry()` helper to construct typed test entries - Add explicit type annotations to mocked functions for better IDE support * test: extract shared toast options type for source control tests Consolidate duplicate `ToastOptions` type definitions across three test files into a single `SourceControlToastTestOptions` type, reducing duplication and improving consistency. * fix(source-control): separate refresh failures from mutation failures Post-mutation refresh failures are logged separately, not surfaced as toasts (mutation already succeeded). Use preventDefault() on retry to prevent sonner's auto-dismiss from swallowing re-raised failures. Consolidate stage/unstage into a shared handler to reduce duplication. * fix(source-control): only dismiss entry failures from the owning worktre Track which worktree owns the shared entry-failure toast slot. When a mutation completes, only dismiss the slot if the completing worktree is the one that raised the failure — a slow retry in one worktree should not erase a failure another worktree has since raised into the slot. * Remove entry mutation status refresh helper Inlined into the caller during consolidation of failure handling and tracking in the source-control entry mutations flow. * Simplify entry mutation refresh without wrapper Call refreshActiveGitStatusAfterMutation directly instead of through the refreshEntryMutationStatus helper. This ensures refresh failures propagate directly from the callback without being caught as mutation failures. Remove tests that validated the wrapper's error handling. --- .../discard-all-failure-description.test.tsx | 96 ++++++++ .../commit/discard-all-sequence.ts | 5 +- .../commit/discard-confirmation.ts | 14 +- ...source-control-entry-failure-toast.test.ts | 149 ++++++++++++ .../source-control-entry-failure-toast.ts | 123 ++++++++++ ...e-control-entry-mutation-failures.test.tsx | 223 ++++++++++++++++++ .../source-control-toast-test-options.ts | 8 + .../source-control/commit/use-bulk-actions.ts | 3 +- .../commit/use-discard-confirmation.ts | 38 ++- .../commit/use-entry-mutations.ts | 69 +++--- src/renderer/src/i18n/locales/en.json | 7 +- 11 files changed, 690 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/source-control/commit/source-control-toast-test-options.ts diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx new file mode 100644 index 00000000000..46760efae81 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-failure-description.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DiscardAllDeps, DiscardAllResult, DiscardAllArea } from './discard-all-sequence' +import type { SourceControlToastTestOptions } from './source-control-toast-test-options' +type DiscardAllRunner = ( + area: DiscardAllArea, + paths: readonly string[], + deps: DiscardAllDeps +) => Promise + +const mocks = vi.hoisted(() => ({ + toastError: vi.fn<(title: string, options?: SourceControlToastTestOptions) => void>(), + runDiscardAllForArea: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, dismiss: vi.fn() } })) +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => undefined })) +vi.mock('@/runtime/runtime-git-client', () => ({ bulkUnstageRuntimeGitPaths: vi.fn() })) +vi.mock('./discard-all-sequence', () => ({ + getDiscardAllPaths: () => [], + runDiscardAllForArea: (area: DiscardAllArea, paths: readonly string[], deps: DiscardAllDeps) => + mocks.runDiscardAllForArea(area, paths, deps) +})) + +import { useSourceControlDiscardConfirmation } from './use-discard-confirmation' +import type { SourceControlEntryGroups } from '../listing/section-order' + +const EMPTY_GROUPS: SourceControlEntryGroups = { unstaged: [], staged: [], untracked: [] } + +function lastDescription(): string | undefined { + return mocks.toastError.mock.lastCall?.[1]?.description +} + +function renderDiscard() { + return renderHook(() => + useSourceControlDiscardConfirmation({ + activeRepoSettings: null, + activeWorktreeId: 'wt-1', + worktreePath: '/repo', + grouped: EMPTY_GROUPS, + isExecutingBulk: false, + setIsExecutingBulk: () => {}, + clearSelection: () => {}, + discardMany: async () => {}, + discardSingle: async () => {}, + refreshActiveGitStatusAfterMutation: async () => {} + }) + ) +} + +async function confirmDiscardOf( + paths: string[], + area: 'staged' | 'unstaged' = 'unstaged' +): Promise { + const { result } = renderDiscard() + await act(async () => { + result.current.requestDiscardPaths(area, paths) + }) + await act(async () => { + result.current.confirmPendingDiscard() + }) +} + +const WRAPPED = "Error invoking remote method 'git:discard': Error: index.lock exists" + +describe('discard-all failure descriptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('unwraps the IPC transport noise on a partial failure, like the per-row toast does', async () => { + mocks.runDiscardAllForArea.mockImplementation(async (_area, _paths, handlers) => { + handlers.onError?.(new Error(WRAPPED)) + return { aborted: false, discarded: [], failed: ['a.ts'] } + }) + + await confirmDiscardOf(['a.ts']) + + expect(lastDescription()).toContain('index.lock exists') + expect(lastDescription()).not.toContain('Error invoking remote method') + }) + + // Why 'staged': `aborted` is set only by the bulkUnstage pre-step, which runs for staged entries. + it('unwraps it on the aborted-before-discard path too', async () => { + mocks.runDiscardAllForArea.mockImplementation(async (_area, _paths, handlers) => { + handlers.onError?.(new Error(WRAPPED)) + return { aborted: true, discarded: [], failed: [] } + }) + + await confirmDiscardOf(['a.ts'], 'staged') + + expect(lastDescription()).toBe('index.lock exists') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-sequence.ts b/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-sequence.ts index 25b3b8e4c8f..2efdd5ad7a6 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-sequence.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-sequence.ts @@ -75,8 +75,9 @@ export type DiscardAllDeps = { discardOne: (path: string) => Promise /** * Called when either the pre-step (bulkUnstage) rejects OR an individual - * `discardOne` rejects. Invoked once per failure so callers can surface - * each error (e.g. a toast per stuck file) rather than swallowing them. + * `discardOne` rejects. Invoked once per failure; callers are expected to + * collect them and report ONE aggregated failure (see + * `use-discard-confirmation.ts`), not a toast per stuck file. */ onError?: (error: unknown) => void } diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control/commit/discard-confirmation.ts index 174bf4169fc..e9f5a65788e 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/discard-confirmation.ts @@ -9,14 +9,22 @@ export type DiscardConfirmationCopy = { confirmLabel: string } +/** + * Untracked and newly-added paths have no HEAD version to restore, so Orca's discard removes the + * working-tree file. Every surface that names the operation must say "delete" for these. + */ +export function isDeleteShapedDiscardEntry( + entry: Pick +): boolean { + return entry.area === 'untracked' || entry.status === 'untracked' || entry.status === 'added' +} + export function getDiscardEntryConfirmationCopy( entry: Pick ): DiscardConfirmationCopy { const name = basename(entry.path) - // Why: untracked and newly-added paths have no HEAD version to restore. - // Orca's discard path removes the working-tree file in those cases. - if (entry.area === 'untracked' || entry.status === 'untracked' || entry.status === 'added') { + if (isDeleteShapedDiscardEntry(entry)) { return { title: translate( 'auto.components.right.sidebar.source.control.discard.confirmation.96c772bee9', diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts new file mode 100644 index 00000000000..92f69010c9f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SourceControlToastTestOptions } from './source-control-toast-test-options' + +const { toastError, toastDismiss } = vi.hoisted(() => ({ + toastError: vi.fn<(title: string, options?: SourceControlToastTestOptions) => void>(), + toastDismiss: vi.fn<(id: string) => void>() +})) +vi.mock('sonner', () => ({ toast: { error: toastError, dismiss: toastDismiss } })) + +const { storeState } = vi.hoisted(() => ({ storeState: { activeWorktreeId: 'wt-1' } })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign(() => undefined, { getState: () => storeState }) +})) + +import { + dismissSourceControlEntryFailureToast, + showSourceControlEntryFailureToast +} from './source-control-entry-failure-toast' + +type FailureToastInput = Parameters[0] + +function lastToast(): { title: string; options: SourceControlToastTestOptions } { + const [title = '', options = {}] = toastError.mock.lastCall ?? [] + return { title, options } +} + +function show(overrides: Partial = {}): void { + showSourceControlEntryFailureToast({ + operation: 'stage', + filePath: 'src/app.ts', + error: new Error('index.lock exists'), + worktreeId: 'wt-1', + worktreeName: 'feature-a', + ...overrides + }) +} + +function clickRetry(): { preventDefault: ReturnType } { + const event = { preventDefault: vi.fn() } + lastToast().options.action?.onClick(event) + return event +} + +describe('showSourceControlEntryFailureToast', () => { + beforeEach(() => { + vi.clearAllMocks() + storeState.activeWorktreeId = 'wt-1' + }) + + it('names the failed operation and the file', () => { + show() + expect(lastToast().title).toBe('Failed to stage “src/app.ts”') + show({ operation: 'unstage' }) + expect(lastToast().title).toBe('Failed to unstage “src/app.ts”') + show({ operation: 'discard' }) + expect(lastToast().title).toBe('Failed to discard “src/app.ts”') + }) + + it('says "delete" for an entry whose discard removes the file rather than restoring it', () => { + // Why: untracked and added paths have no HEAD version, so the row button and the confirmation + // dialog both say "delete" — the failure must not contradict the verb the user pressed. + show({ operation: 'discard', deleteShaped: true }) + expect(lastToast().title).toBe('Failed to delete “src/app.ts”') + }) + + it('keeps the underlying detail but drops the Electron IPC wrapper', () => { + show({ + error: new Error("Error invoking remote method 'git:stage': Error: index.lock exists") + }) + expect(lastToast().options.description).toBe('index.lock exists') + }) + + it('uses one stable slot for entry failures', () => { + show() + expect(lastToast().options.id).toBe('source-control-entry-mutation') + storeState.activeWorktreeId = 'wt-2' + show({ worktreeId: 'wt-2', worktreeName: 'feature-b' }) + expect(lastToast().options.id).toBe('source-control-entry-mutation') + }) + + it('still reports a failure belonging to a worktree the user has left, naming it', () => { + // Why: suppressing the ACTION on a worktree mismatch is right; suppressing the REPORT would + // reintroduce exactly the silent failure this module exists to remove. + storeState.activeWorktreeId = 'wt-2' + show({ worktreeId: 'wt-1', worktreeName: 'feature-a', onRetry: vi.fn() }) + + expect(toastError).toHaveBeenCalledTimes(1) + expect(lastToast().title).toBe('Failed to stage “src/app.ts” in feature-a') + expect(lastToast().options.action).toBeUndefined() + expect(lastToast().options.duration).toBeUndefined() + }) + + it('offers Retry, and a readable lifetime, only in the worktree that failed', () => { + const onRetry = vi.fn() + show({ onRetry }) + expect(lastToast().options.action?.label).toBe('Retry') + expect(lastToast().options.duration).toBe(10000) + clickRetry() + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it('keeps sonner from auto-dismissing the slot the retry is about to re-raise into', () => { + // Why: sonner's post-click removal is scheduled by id, so it would swallow a re-failure raised + // within ~200ms; preventDefault hands the slot's lifetime to the retry itself. + const onRetry = vi.fn() + show({ onRetry }) + + expect(clickRetry().preventDefault).toHaveBeenCalledTimes(1) + expect(toastDismiss).not.toHaveBeenCalled() + }) + + it('retires a Retry action that became stale after a worktree switch', () => { + const onRetry = vi.fn() + show({ onRetry }) + storeState.activeWorktreeId = 'wt-2' + + clickRetry() + + expect(onRetry).not.toHaveBeenCalled() + expect(toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation') + }) + + it('clears the shared slot when an attempt finally lands', () => { + show() + dismissSourceControlEntryFailureToast('wt-1') + expect(toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation') + }) + + it('leaves a failure another worktree raised into the slot alone', () => { + // Why: a retry still in flight in the worktree the user left must not erase the failure the + // worktree they switched to has since raised into the shared slot. + show({ worktreeId: 'wt-1' }) + storeState.activeWorktreeId = 'wt-2' + show({ worktreeId: 'wt-2', worktreeName: 'feature-b' }) + + dismissSourceControlEntryFailureToast('wt-1') + expect(toastDismiss).not.toHaveBeenCalled() + + dismissSourceControlEntryFailureToast('wt-2') + expect(toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation') + }) + + it('omits the description when the failure carried no readable message', () => { + show({ error: 'not an Error' }) + expect(lastToast().options.description).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts new file mode 100644 index 00000000000..13ab65c6fb8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-failure-toast.ts @@ -0,0 +1,123 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { readIpcErrorMessage } from '@/lib/ipc-error' +import { useAppStore } from '@/store' + +export type SourceControlEntryOperation = 'stage' | 'unstage' | 'discard' + +const ENTRY_FAILURE_TOAST_ID = 'source-control-entry-mutation' + +// Why: worktreeId is nullable, so an occupancy wrapper distinguishes an empty slot from a null-owned one. +let entryFailureSlotOwner: { worktreeId: string | null } | null = null + +/** + * Clears the shared entry-failure slot once an attempt — or its retry — lands, but only when the + * completing attempt is the one that filled it: a slow retry in a worktree the user has left must + * not erase a failure the worktree they moved to has since raised into the same slot. + */ +export function dismissSourceControlEntryFailureToast(worktreeId: string | null): void { + if (!entryFailureSlotOwner || entryFailureSlotOwner.worktreeId !== worktreeId) { + return + } + entryFailureSlotOwner = null + toast.dismiss(ENTRY_FAILURE_TOAST_ID) +} + +function entryFailureTitle( + operation: SourceControlEntryOperation, + filePath: string, + deleteShaped: boolean +): string { + switch (operation) { + case 'stage': + return translate( + 'auto.components.right.sidebar.SourceControl.entryStageFailed', + 'Failed to stage “{{value0}}”', + { value0: filePath } + ) + case 'unstage': + return translate( + 'auto.components.right.sidebar.SourceControl.entryUnstageFailed', + 'Failed to unstage “{{value0}}”', + { value0: filePath } + ) + case 'discard': + return deleteShaped + ? translate( + 'auto.components.right.sidebar.SourceControl.entryDeleteFailed', + 'Failed to delete “{{value0}}”', + { value0: filePath } + ) + : translate( + 'auto.components.right.sidebar.SourceControl.entryDiscardFailed', + 'Failed to discard “{{value0}}”', + { value0: filePath } + ) + } +} + +/** + * Per-row stage/unstage/discard failure. Bulk callers aggregate their own failures into one toast + * instead — see `reportBulkMutationFailure` and the discard-all summary in `use-discard-confirmation`. + * + * A failure belonging to a worktree the user has since left is still reported — silence is the bug + * this exists to remove — but it names that worktree and offers no action, because every recovery + * affordance here is bound to the repo the attempt ran against. + */ +export function showSourceControlEntryFailureToast({ + operation, + filePath, + deleteShaped = false, + error, + worktreeId, + worktreeName, + onRetry +}: { + operation: SourceControlEntryOperation + filePath: string + /** True when this discard deletes the file rather than restoring it — see `discard-confirmation`. */ + deleteShaped?: boolean + error: unknown + /** The worktree the failed attempt ran against. */ + worktreeId: string | null + /** Shown only when the toast no longer belongs to the active worktree. */ + worktreeName: string | null + onRetry?: () => void +}): void { + const isActiveWorktree = useAppStore.getState().activeWorktreeId === worktreeId + const title = entryFailureTitle(operation, filePath, deleteShaped) + const offerRetry = Boolean(onRetry) && isActiveWorktree + entryFailureSlotOwner = { worktreeId } + toast.error( + isActiveWorktree || !worktreeName + ? title + : translate( + 'auto.components.right.sidebar.SourceControl.entryFailedInWorkspace', + '{{value0}} in {{value1}}', + { value0: title, value1: worktreeName } + ), + { + id: ENTRY_FAILURE_TOAST_ID, + description: readIpcErrorMessage(error), + // Why: sonner's 4s default retires the Retry button before a user reading the path can click it. + duration: offerRetry ? 10000 : undefined, + action: + offerRetry && onRetry + ? { + label: translate('auto.components.right.sidebar.SourceControl.286dbda4d6', 'Retry'), + onClick: (event) => { + // Why: sonner dismisses on action click and its pending removal filters by id, so a + // retry that re-fails inside that window would take the re-raised toast with it. The + // caller owns this slot instead: it dismisses on success and re-raises on failure. + event.preventDefault() + if (useAppStore.getState().activeWorktreeId !== worktreeId) { + dismissSourceControlEntryFailureToast(worktreeId) + return + } + onRetry() + } + } + : undefined + } + ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx new file mode 100644 index 00000000000..94a78b4a008 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx @@ -0,0 +1,223 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import type { SourceControlToastTestOptions } from './source-control-toast-test-options' + +const mocks = vi.hoisted(() => ({ + toastError: vi.fn<(title: string, options?: SourceControlToastTestOptions) => void>(), + toastDismiss: vi.fn<(id: string) => void>(), + stagePath: vi.fn(), + unstagePath: vi.fn(), + discardPath: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { error: mocks.toastError, dismiss: mocks.toastDismiss, message: vi.fn() } +})) +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => undefined })) +vi.mock('@/components/editor/editor-autosave', () => ({ + notifyEditorExternalFileChange: vi.fn(), + requestEditorSaveQuiesce: vi.fn(async () => {}) +})) +vi.mock('@/runtime/runtime-git-client', () => ({ + stageRuntimeGitPath: (...args: unknown[]) => mocks.stagePath(...args), + unstageRuntimeGitPath: (...args: unknown[]) => mocks.unstagePath(...args), + discardRuntimeGitPath: (...args: unknown[]) => mocks.discardPath(...args), + bulkDiscardRuntimeGitPaths: vi.fn(), + bulkUnstageRuntimeGitPaths: vi.fn() +})) +vi.mock('@/store', () => ({ + useAppStore: Object.assign(() => undefined, { + getState: () => ({ settings: { activeRuntimeEnvironmentId: null }, activeWorktreeId: 'wt-1' }) + }) +})) + +import { useSourceControlDiscardConfirmation } from './use-discard-confirmation' +import { useSourceControlEntryMutations } from './use-entry-mutations' +import type { SourceControlEntryGroups } from '../listing/section-order' + +const EMPTY_GROUPS: SourceControlEntryGroups = { unstaged: [], staged: [], untracked: [] } + +function entry( + path: string, + status: GitStatusEntry['status'] = 'modified', + area: GitStatusEntry['area'] = 'unstaged' +): GitStatusEntry { + return { path, status, area } +} + +function lastToast(): { title: string; options: SourceControlToastTestOptions } { + const [title = '', options = {}] = mocks.toastError.mock.lastCall ?? [] + return { title, options } +} + +function clickRetry(): void { + lastToast().options.action?.onClick({ preventDefault: () => {} }) +} + +function renderMutations() { + return renderHook(() => + useSourceControlEntryMutations({ + activeRepoSettings: null, + activeWorktreeId: 'wt-1', + worktreePath: '/repo', + refreshActiveGitStatusAfterMutation: async () => {} + }) + ) +} + +function renderDiscard(discardSingle: (path: string) => Promise) { + return renderHook(() => + useSourceControlDiscardConfirmation({ + activeRepoSettings: null, + activeWorktreeId: 'wt-1', + worktreePath: '/repo', + grouped: EMPTY_GROUPS, + isExecutingBulk: false, + setIsExecutingBulk: () => {}, + clearSelection: () => {}, + discardMany: async () => {}, + discardSingle, + refreshActiveGitStatusAfterMutation: async () => {} + }) + ) +} + +describe('source-control entry mutation failures', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('reports a failed stage instead of leaving the row unchanged and silent', async () => { + mocks.stagePath.mockRejectedValue(new Error('index.lock exists')) + const { result } = renderMutations() + + await act(async () => { + await result.current.handleStage('src/app.ts') + }) + + expect(mocks.toastError).toHaveBeenCalledTimes(1) + expect(lastToast().title).toBe('Failed to stage “src/app.ts”') + expect(lastToast().options.description).toBe('index.lock exists') + }) + + it('retries the same path from the stage failure toast', async () => { + mocks.stagePath.mockRejectedValueOnce(new Error('index.lock exists')) + mocks.stagePath.mockResolvedValueOnce(undefined) + const { result } = renderMutations() + + await act(async () => { + await result.current.handleStage('src/app.ts') + }) + await act(async () => { + clickRetry() + }) + + expect(mocks.stagePath).toHaveBeenCalledTimes(2) + expect(mocks.stagePath.mock.calls[1]?.[1]).toBe('src/app.ts') + // Why: the retry succeeded, so no second failure toast — and the first one is cleared. + expect(mocks.toastError).toHaveBeenCalledTimes(1) + expect(mocks.toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation') + }) + + it('re-raises the failure toast when the retry fails again', async () => { + // Why: sonner removes an action-clicked toast by id ~200ms later, so a fast re-failure could be + // swallowed; the toast must survive the retry and show the second error. + mocks.stagePath.mockRejectedValueOnce(new Error('index.lock exists')) + mocks.stagePath.mockRejectedValueOnce(new Error('still locked')) + const { result } = renderMutations() + + await act(async () => { + await result.current.handleStage('src/app.ts') + }) + await act(async () => { + clickRetry() + }) + + expect(mocks.toastError).toHaveBeenCalledTimes(2) + expect(lastToast().options.id).toBe('source-control-entry-mutation') + expect(lastToast().options.description).toBe('still locked') + expect(mocks.toastDismiss).not.toHaveBeenCalled() + }) + + it('reports a failed unstage', async () => { + mocks.unstagePath.mockRejectedValue(new Error('bad object')) + const { result } = renderMutations() + + await act(async () => { + await result.current.handleUnstage('src/app.ts') + }) + + expect(lastToast().title).toBe('Failed to unstage “src/app.ts”') + }) + + it('leaves a successful stage silent, and clears a stale failure it supersedes', async () => { + mocks.stagePath.mockRejectedValueOnce(new Error('index.lock exists')) + mocks.stagePath.mockResolvedValueOnce(undefined) + const { result } = renderMutations() + + await act(async () => { + await result.current.handleStage('src/app.ts') + }) + mocks.toastError.mockClear() + await act(async () => { + await result.current.handleStage('src/other.ts') + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + expect(mocks.toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation') + }) + + it('reports a failed per-row discard — the destructive action must never fail silently', async () => { + const discardSingle = vi.fn(async () => { + throw new Error('unable to write file') + }) + const { result } = renderDiscard(discardSingle) + + await act(async () => { + result.current.requestDiscardEntry(entry('src/app.ts')) + }) + await act(async () => { + result.current.confirmPendingDiscard() + }) + + expect(mocks.toastError).toHaveBeenCalledTimes(1) + expect(lastToast().title).toBe('Failed to discard “src/app.ts”') + expect(lastToast().options.description).toBe('unable to write file') + }) + + it('does not put a destructive retry in the failure toast', async () => { + const discardSingle = vi.fn(async () => { + throw new Error('unable to write file') + }) + const { result } = renderDiscard(discardSingle) + + await act(async () => { + result.current.requestDiscardEntry(entry('src/app.ts')) + }) + await act(async () => { + result.current.confirmPendingDiscard() + }) + expect(lastToast().options.action).toBeUndefined() + expect(discardSingle).toHaveBeenCalledTimes(1) + }) + + it('says "delete" when the failed discard would have removed an untracked file', async () => { + const discardSingle = vi + .fn<(path: string) => Promise>() + .mockRejectedValue(new Error('unable to write file')) + const { result } = renderDiscard(discardSingle) + + await act(async () => { + result.current.requestDiscardEntry(entry('new.ts', 'untracked', 'untracked')) + }) + await act(async () => { + result.current.confirmPendingDiscard() + }) + + expect(lastToast().title).toBe('Failed to delete “new.ts”') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-toast-test-options.ts b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-toast-test-options.ts new file mode 100644 index 00000000000..76767a8c006 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-toast-test-options.ts @@ -0,0 +1,8 @@ +export type SourceControlToastActionEvent = { preventDefault: () => void } + +export type SourceControlToastTestOptions = { + id?: string + description?: string + duration?: number + action?: { label: string; onClick: (event: SourceControlToastActionEvent) => void } +} diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts index 4bde851d5b0..bc39e119b54 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { translate } from '@/i18n/i18n' +import { readIpcErrorMessage } from '@/lib/ipc-error' import { bulkStageRuntimeGitPaths, bulkUnstageRuntimeGitPaths, @@ -19,7 +20,7 @@ function reportBulkMutationFailure(error: unknown): void { 'auto.components.right.sidebar.use.source.control.bulk.actions.2f67630884', 'Bulk stage/unstage failed' ), - { description: error instanceof Error ? error.message : undefined } + { description: readIpcErrorMessage(error) } ) } diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts index ab0d5f045a6..f158c971a23 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-discard-confirmation.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from 'react' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' +import { basename } from '@/lib/path' import { bulkUnstageRuntimeGitPaths, type RuntimeGitContext } from '@/runtime/runtime-git-client' import { translate } from '@/i18n/i18n' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' @@ -9,6 +10,12 @@ import { runDiscardAllForArea, type DiscardAllArea } from './discard-all-sequence' +import { isDeleteShapedDiscardEntry } from './discard-confirmation' +import { readIpcErrorMessage } from '@/lib/ipc-error' +import { + dismissSourceControlEntryFailureToast, + showSourceControlEntryFailureToast +} from './source-control-entry-failure-toast' import type { PendingDiscardConfirmation } from './discard-dialog' import type { SourceControlEntryGroups } from '../listing/section-order' @@ -44,15 +51,28 @@ export function useSourceControlDiscardConfirmation({ } const handleDiscard = useCallback( - async (filePath: string) => { + async (entry: GitStatusEntry): Promise => { + // Why: only the discard itself is caught here — a refresh rejection would otherwise be + // reported as "Failed to discard" for a discard that already landed. try { - await discardSingle(filePath) - await refreshActiveGitStatusAfterMutation() - } catch { - // Why: per-row discard is fire-and-forget; bulk callers use discardSingle directly to aggregate failures into one toast. + await discardSingle(entry.path) + } catch (error) { + console.error('[SourceControl] discard failed', error) + // Why: bulk callers use discardSingle directly so they can aggregate failures into one toast. + showSourceControlEntryFailureToast({ + operation: 'discard', + filePath: entry.path, + deleteShaped: isDeleteShapedDiscardEntry(entry), + error, + worktreeId: activeWorktreeId, + worktreeName: worktreePath ? basename(worktreePath) : null + }) + return } + dismissSourceControlEntryFailureToast(activeWorktreeId) + await refreshActiveGitStatusAfterMutation() }, - [discardSingle, refreshActiveGitStatusAfterMutation] + [activeWorktreeId, discardSingle, refreshActiveGitStatusAfterMutation, worktreePath] ) // Why: "Discard all" skips unresolved/resolved_locally rows (discarding can re-create the conflict or lose the resolution; no v1 UX for it). @@ -96,11 +116,11 @@ export function useSourceControlDiscardConfirmation({ 'auto.components.right.sidebar.SourceControl.a5e5a11090', 'Discard all failed — unable to unstage files before discard' ), - { description: errors[0] instanceof Error ? errors[0].message : undefined } + { description: readIpcErrorMessage(errors[0]) } ) } else if (result.failed.length > 0) { // Why: show only the first error + a sample of failed paths to avoid a huge toast body on bulk failures. - const firstMsg = errors[0] instanceof Error ? errors[0].message : undefined + const firstMsg = readIpcErrorMessage(errors[0]) const sample = result.failed.slice(0, 3).join(', ') const more = result.failed.length > 3 ? `, +${result.failed.length - 3} more` : '' toast.error( @@ -182,7 +202,7 @@ export function useSourceControlDiscardConfirmation({ } setPendingDiscard(null) if (pending.kind === 'entry') { - void handleDiscard(pending.entry.path) + void handleDiscard(pending.entry) return } void handleRevertAllInArea(pending.area, pending.paths) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts index c9deee07487..415601c4488 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts @@ -4,6 +4,7 @@ import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave' import { getConnectionId } from '@/lib/connection-context' +import { basename } from '@/lib/path' import { bulkDiscardRuntimeGitPaths, discardRuntimeGitPath, @@ -12,6 +13,10 @@ import { type RuntimeGitContext } from '@/runtime/runtime-git-client' import { useAppStore } from '@/store' +import { + dismissSourceControlEntryFailureToast, + showSourceControlEntryFailureToast +} from './source-control-entry-failure-toast' export function useSourceControlEntryMutations({ activeRepoSettings, @@ -24,16 +29,21 @@ export function useSourceControlEntryMutations({ worktreePath: string | null refreshActiveGitStatusAfterMutation: () => Promise }) { - const handleStage = useCallback( - async (filePath: string) => { + // Why: named function expression so the failure toast's Retry can re-enter the same attempt. + const runEntryMutation = useCallback( + async function runEntryMutation( + operation: 'stage' | 'unstage', + filePath: string, + mutate: (context: RuntimeGitContext, filePath: string) => Promise + ): Promise { if (!worktreePath) { return } try { const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await stageRuntimeGitPath( + await mutate( { - // Why: route staging by the repo OWNER host, not the focused runtime. + // Why: route the mutation by the repo OWNER host, not the focused runtime. settings: activeRepoSettings, worktreeId: activeWorktreeId, worktreePath, @@ -41,40 +51,41 @@ export function useSourceControlEntryMutations({ }, filePath ) - await refreshActiveGitStatusAfterMutation() } catch (error) { - console.error('[SourceControl] stage failed', error) + console.error(`[SourceControl] ${operation} failed`, error) + showSourceControlEntryFailureToast({ + operation, + filePath, + error, + worktreeId: activeWorktreeId, + worktreeName: worktreePath ? basename(worktreePath) : null, + onRetry: () => { + void runEntryMutation(operation, filePath, mutate) + } + }) + return } + // Why: the mutation landed, so clear any failure this worktree's attempts left in the slot — + // a failure another worktree raised meanwhile is not ours to dismiss. + dismissSourceControlEntryFailureToast(activeWorktreeId) + // Why: refreshing outside the try keeps a refresh failure from being reported as "Failed to stage"; the refresher reports its own. + await refreshActiveGitStatusAfterMutation() }, [activeRepoSettings, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] ) + const handleStage = useCallback( + (filePath: string): Promise => runEntryMutation('stage', filePath, stageRuntimeGitPath), + [runEntryMutation] + ) + const handleUnstage = useCallback( - async (filePath: string) => { - if (!worktreePath) { - return - } - try { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - await unstageRuntimeGitPath( - { - // Why: route unstaging by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, - filePath - ) - await refreshActiveGitStatusAfterMutation() - } catch (error) { - console.error('[SourceControl] unstage failed', error) - } - }, - [activeRepoSettings, worktreePath, activeWorktreeId, refreshActiveGitStatusAfterMutation] + (filePath: string): Promise => + runEntryMutation('unstage', filePath, unstageRuntimeGitPath), + [runEntryMutation] ) - // Why: discardSingle throws so bulk callers can aggregate failures into one toast; handleDiscard swallows for per-row fire-and-forget. + // Why: discardSingle throws so bulk callers can aggregate failures into one toast; the per-row caller reports its own. const discardSingle = useCallback( async (filePath: string) => { if (!worktreePath || !activeWorktreeId) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index aecae8bc23d..cf5cfc46353 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12372,7 +12372,12 @@ "a4e93c21d7": "Current branch: {{value0}}", "c7d4e2f801": "Change base ref: {{value0}}", "f3a1b8c204": "upstream", - "createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR." + "createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR.", + "entryStageFailed": "Failed to stage “{{value0}}”", + "entryUnstageFailed": "Failed to unstage “{{value0}}”", + "entryDiscardFailed": "Failed to discard “{{value0}}”", + "entryDeleteFailed": "Failed to delete “{{value0}}”", + "entryFailedInWorkspace": "{{value0}} in {{value1}}" }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", From d5be0d69e7df468ad208a6e212d7c8502aa8392d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:54:31 -0700 Subject: [PATCH 09/43] Add copy button to code blocks (#20357) * feat(native-chat): add copy button to code blocks Enable users to copy code snippets directly from chat messages via a dedicated copy button on fenced code blocks. Supports language detection and integrates with markdown rendering via a `renderCodeBlock` prop. * i18n: add English copy code button label * refactor: use React.isValidElement type parameters for type narrowing - Specify props types as type parameters to React.isValidElement instead of casting after the fact - Allows TypeScript to narrow element.props type automatically - Eliminates manual type assertions in extractCodeText and extractCodeFenceLanguage --- .../native-chat/NativeChatCodeBlock.test.tsx | 31 +++++++++ .../native-chat/NativeChatCodeBlock.tsx | 68 +++++++++++++++++++ .../native-chat/NativeChatMessageRow.test.tsx | 28 +++++++- .../native-chat/NativeChatMessageRow.tsx | 3 + .../sidebar/CommentMarkdown.test.tsx | 28 ++++++++ .../components/sidebar/CommentMarkdown.tsx | 13 ++-- .../comment-markdown-element-renderers.tsx | 18 ++++- src/renderer/src/i18n/locales/en.json | 3 +- 8 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx b/src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx new file mode 100644 index 00000000000..bc0658525ba --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatCodeBlock.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NativeChatCodeBlock } from './NativeChatCodeBlock' + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('NativeChatCodeBlock', () => { + it('copies only the fenced code and confirms success', async () => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined) + Object.assign(window, { api: { ui: { writeClipboardText } } }) + + render( + + {'const answer = 42\nconsole.log(answer)\n'} + + ) + + expect(screen.getByText('TypeScript')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Copy code' })) + + await waitFor(() => { + expect(writeClipboardText).toHaveBeenCalledWith('const answer = 42\nconsole.log(answer)\n') + }) + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx b/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx new file mode 100644 index 00000000000..7965668d2dc --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { Code2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { getCodeBlockLanguageLabel } from '@/components/editor/rich-markdown-code-block-languages' +import { NativeChatCopyButton } from './NativeChatCopyButton' + +/** Code fences need their own copy target rather than the whole chat message. */ +export function NativeChatCodeBlock({ + children, + language +}: { + children?: React.ReactNode + language?: string +}): React.JSX.Element { + const code = extractCodeText(children) + + return ( +
+ {language ? ( +
+ + + {getCodeBlockLanguageLabel(language)} + + {code ? ( + + ) : null} +
+ ) : null} +
+        {children}
+      
+ {code && !language ? ( + + ) : null} +
+ ) +} + +function extractCodeText(node: React.ReactNode): string { + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + if (Array.isArray(node)) { + return node.map(extractCodeText).join('') + } + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + return extractCodeText(node.props.children) + } + return '' +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index b2a55abf154..6450face0ba 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { MessageRow } from './NativeChatMessageRow' @@ -24,6 +24,32 @@ function renderMessage(role: NativeChatMessage['role'], timestamp: number | null } describe('MessageRow control visibility', () => { + it('renders and copies a fenced code block through the markdown path', async () => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined) + Object.assign(window, { api: { ui: { writeClipboardText } } }) + + render( + + ) + + expect(screen.getByText('ts')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Copy code' })) + + await waitFor(() => { + expect(writeClipboardText).toHaveBeenCalledWith('const answer = 42\n') + }) + }) + it('appends time to the existing agent controls and inherits their reveal', () => { renderMessage('assistant') const copy = screen.getByRole('button', { name: 'Copy message' }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index d73772dfaf9..fcad31aac13 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -10,6 +10,7 @@ import type { } from '../../../../shared/native-chat-types' import { deriveNativeChatRowContent } from './native-chat-row-content' import { NativeChatToolRun } from './NativeChatToolRun' +import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatNoticeRow } from './NativeChatNoticeRow' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' import { @@ -122,6 +123,7 @@ export const MessageRow = memo(function MessageRow({ content={markdown} variant="document" className="text-sm" + renderCodeBlock={NativeChatCodeBlock} onLinkClick={onLinkClick} allowFileUriLinks={allowFileUriLinks} /> @@ -175,6 +177,7 @@ export const MessageRow = memo(function MessageRow({ content={markdown} variant="document" className="text-sm" + renderCodeBlock={NativeChatCodeBlock} onLinkClick={onLinkClick} allowFileUriLinks={allowFileUriLinks} linkifyFilePaths={onLinkClick !== undefined} diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx index 644469ae21e..0f3974c5578 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx @@ -1,6 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import CommentMarkdown, { remarkGitHubReferences } from './CommentMarkdown' +import { NativeChatCodeBlock } from '@/components/native-chat/NativeChatCodeBlock' describe('CommentMarkdown', () => { it('marks compact headings so a parent can opt into block flow', () => { @@ -224,6 +225,33 @@ describe('CommentMarkdown', () => { expect(markup).not.toContain(' { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('aria-label="Copy code"') + expect(markup).toContain('data-code-language="ts"') + expect(markup).toContain('const answer = 42') + }) + + it('does not invent a language label for a bare code fence', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('aria-label="Copy code"') + expect(markup).not.toContain('data-code-language') + }) + it('keeps compact mermaid fences as bounded source blocks', () => { const markup = renderToStaticMarkup( B;\n```'} /> diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 0999c23c8c6..8b862c90c78 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -11,7 +11,8 @@ import { createDocumentCommentMarkdownComponents, documentCommentMarkdownComponents, isTrustedCompactImageSrc, - type CommentMarkdownLinkClickHandler + type CommentMarkdownLinkClickHandler, + type DocumentCodeBlockRenderer } from './comment-markdown-element-renderers' import { remarkNativeChatFileLinks } from './comment-markdown-native-chat-file-links' @@ -188,6 +189,7 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { allowFileUriLinks?: boolean linkifyFilePaths?: boolean expandImages?: boolean + renderCodeBlock?: DocumentCodeBlockRenderer } // Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref @@ -204,6 +206,7 @@ const CommentMarkdown = React.memo( allowFileUriLinks = false, linkifyFilePaths = false, expandImages = false, + renderCodeBlock, ...rest }, ref @@ -211,15 +214,17 @@ const CommentMarkdown = React.memo( const components = React.useMemo(() => { if (!onLinkClick) { return variant === 'document' - ? documentCommentMarkdownComponents + ? renderCodeBlock + ? createDocumentCommentMarkdownComponents(undefined, renderCodeBlock) + : documentCommentMarkdownComponents : expandImages ? createCompactCommentMarkdownComponents(undefined, true) : compactCommentMarkdownComponents } return variant === 'document' - ? createDocumentCommentMarkdownComponents(onLinkClick) + ? createDocumentCommentMarkdownComponents(onLinkClick, renderCodeBlock) : createCompactCommentMarkdownComponents(onLinkClick, expandImages) - }, [expandImages, variant, onLinkClick]) + }, [expandImages, renderCodeBlock, variant, onLinkClick]) const activeRemarkPlugins = React.useMemo(() => { const plugins = linkifyFilePaths ? [...remarkPlugins, remarkNativeChatFileLinks] diff --git a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx index bb82a47356b..7d4e6d1b3bc 100644 --- a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx +++ b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx @@ -15,6 +15,19 @@ export type CommentMarkdownLinkClickHandler = ( href: string | undefined ) => void +export type DocumentCodeBlockRenderer = (props: { + children?: React.ReactNode + language?: string +}) => React.JSX.Element + +function extractCodeFenceLanguage(children: React.ReactNode): string | undefined { + const child = React.Children.toArray(children)[0] + if (!React.isValidElement<{ className?: string }>(child)) { + return undefined + } + return child.props.className?.match(/(?:^|\s)language-([^\s]+)/)?.[1] +} + export function isTrustedCompactImageSrc(src: string | undefined): src is string { if (!src) { return false @@ -223,7 +236,8 @@ export function createCompactCommentMarkdownComponents( } export function createDocumentCommentMarkdownComponents( - onLinkClick?: CommentMarkdownLinkClickHandler + onLinkClick?: CommentMarkdownLinkClickHandler, + renderCodeBlock?: DocumentCodeBlockRenderer ): Components { return { p: ({ children }) =>

{children}

, @@ -259,6 +273,8 @@ export function createDocumentCommentMarkdownComponents( pre: ({ children }) => isMermaidPre(children) ? ( <>{children} + ) : renderCodeBlock ? ( + renderCodeBlock({ children, language: extractCodeFenceLanguage(children) }) ) : (
           {children}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index cf5cfc46353..e2758ee2cc5 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -17316,7 +17316,8 @@
       "drop": {
         "title": "Drop to attach to this chat",
         "subtitle": "Files are added to your message as paths the agent can read."
-      }
+      },
+      "copyCode": "Copy code"
     },
     "tab": {
       "bar": {

From 6c70801f72e318e120c8dfcec885215aa506e90f Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 14 Sep 2026 13:07:27 -0700
Subject: [PATCH 10/43] fix(source-control): prevent text wrapping in section
 headers and action buttons (#20046)

* fix(source-control): prevent text wrapping in section headers and action

Use flex layout constraints (flex-1, shrink-0) and text truncation instead of
wrapping to keep section labels and action buttons on a single line in the
right sidebar.

* test(source-control): add section action button alignment tests

Ensure View all button stays on single line with icon actions in
crowded section headers. Pin layout constraints (shrink-0, flex-wrap,
whitespace-nowrap) to prevent regression.

* Rely on Button base styles for action label wrapping

Remove redundant shrink-0 and whitespace-nowrap utilities from
section action buttons. These should be supplied by the Button
component's base variant, not duplicated at each usage site.
---
 .../listing/section-action-buttons.test.tsx   | 151 ++++++++++++++++++
 .../listing/section-header.test.tsx           |  47 ++++++
 .../source-control/listing/section-header.tsx |  16 +-
 .../listing/uncommitted-sections.tsx          | 104 ++++++------
 4 files changed, 258 insertions(+), 60 deletions(-)
 create mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx
 create mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/section-header.test.tsx

diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx
new file mode 100644
index 00000000000..d52f62eb654
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx
@@ -0,0 +1,151 @@
+// @vitest-environment happy-dom
+
+import '@testing-library/jest-dom/vitest'
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/react'
+
+import { TooltipProvider } from '@/components/ui/tooltip'
+import type { GitBranchCompareSummary } from '../../../../../../shared/git-diff-compare-types'
+import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
+import { SourceControlBranchSection } from './branch-section'
+import { SourceControlUncommittedSections } from './uncommitted-sections'
+import type { SourceControlDisplaySection, SourceControlDisplaySectionId } from './section-order'
+
+afterEach(cleanup)
+
+const BRANCH_SUMMARY: GitBranchCompareSummary = {
+  baseRef: 'origin/main',
+  baseOid: 'base-oid',
+  compareRef: 'feature',
+  headOid: 'head-oid',
+  mergeBase: 'merge-base-oid',
+  changedFiles: 1,
+  status: 'ready'
+}
+
+const UNSTAGED_ENTRY: GitStatusEntry = {
+  path: 'src/app.ts',
+  status: 'modified',
+  area: 'unstaged'
+}
+
+// Sections render collapsed so the assertions see the header actions alone,
+// without the virtualized file list.
+function renderBranchSection(): void {
+  render(
+    
+      
+    
+  )
+}
+
+// An unstaged section with one plain entry surfaces Discard all + Stage all
+// next to View all — the crowded case the single-line layout has to survive.
+function renderUncommittedSections(): void {
+  const section: SourceControlDisplaySection = {
+    id: 'unstaged',
+    area: 'unstaged',
+    items: [UNSTAGED_ENTRY]
+  }
+  const unfilteredById = new Map([
+    ['unstaged', section]
+  ])
+  render(
+    
+      
+    
+  )
+}
+
+describe('source control section header actions', () => {
+  it('groups the uncommitted View all button with the icon actions in one row', () => {
+    renderUncommittedSections()
+
+    const viewAll = screen.getByRole('button', { name: 'View all' })
+    const discardAll = screen.getByRole('button', { name: 'Discard all' })
+    const stageAll = screen.getByRole('button', { name: 'Stage all' })
+
+    // One shared parent, not a sibling of the icon cluster: that grouping is
+    // what keeps View all on the icons' line instead of below them.
+    expect(viewAll.parentElement).toBe(discardAll.parentElement)
+    expect(viewAll.parentElement).toBe(stageAll.parentElement)
+    expect(viewAll.parentElement?.className).not.toContain('flex-wrap')
+  })
+
+  it('seats the uncommitted action row in a header slot that cannot shrink or wrap', () => {
+    renderUncommittedSections()
+
+    const actionsSlot = screen.getByRole('button', { name: 'View all' }).parentElement
+      ?.parentElement
+    expect(actionsSlot).toHaveClass('shrink-0')
+    expect(actionsSlot?.className).not.toContain('flex-wrap')
+  })
+
+  it('seats the branch View all button in a header slot that cannot shrink or wrap', () => {
+    renderBranchSection()
+
+    const actionsSlot = screen.getByRole('button', { name: 'View all' }).parentElement
+    expect(actionsSlot).toHaveClass('shrink-0')
+    expect(actionsSlot?.className).not.toContain('flex-wrap')
+  })
+
+  it('keeps the View all label on a single line', () => {
+    renderBranchSection()
+
+    // Supplied by the shared Button base variant; pinned here so a change to that
+    // variant can't silently start wrapping these labels.
+    expect(screen.getByRole('button', { name: 'View all' })).toHaveClass('whitespace-nowrap')
+  })
+})
diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/section-header.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/section-header.test.tsx
new file mode 100644
index 00000000000..af50484e1b1
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/source-control/listing/section-header.test.tsx
@@ -0,0 +1,47 @@
+// @vitest-environment happy-dom
+import { render, screen, fireEvent } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { SectionHeader } from './section-header'
+
+describe('SectionHeader', () => {
+  it('renders section label and file count without wrapping classes', () => {
+    const { container } = render(
+      Action}
+      />
+    )
+
+    expect(screen.getByText('Changes')).toBeDefined()
+    expect(screen.getByText('13')).toBeDefined()
+    expect(screen.getByRole('button', { name: /Changes/i })).toBeDefined()
+
+    // Ensure flex-wrap is not used on container or action clusters
+    const sectionRow = container.querySelector('.group\\/section')
+    expect(sectionRow).not.toBeNull()
+    expect(sectionRow?.className).not.toContain('flex-wrap')
+    expect(sectionRow?.className).toContain('flex')
+
+    // Ensure actions container does not wrap and has shrink-0
+    const actionsContainer = sectionRow?.lastElementChild
+    expect(actionsContainer?.className).toContain('shrink-0')
+    expect(actionsContainer?.className).not.toContain('flex-wrap')
+
+    // Ensure label has truncate to prevent overflowing row on narrow widths
+    const labelSpan = screen.getByText('Changes')
+    expect(labelSpan.className).toContain('truncate')
+  })
+
+  it('calls onToggle when header button is clicked', () => {
+    const onToggle = vi.fn()
+    render(
+      
+    )
+
+    fireEvent.click(screen.getByRole('button', { name: /Staged Changes/i }))
+    expect(onToggle).toHaveBeenCalledTimes(1)
+  })
+})
diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx
index b404b6900ab..1ca0159b14b 100644
--- a/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx
+++ b/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx
@@ -25,21 +25,23 @@ export function SectionHeader({
   // Why: shared rounded container so the hover background spans the whole row instead of clipping around the label.
   return (
     
-
+
-
{actions}
+
{actions}
) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx index c8591534055..6cc44cb95fc 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx @@ -112,58 +112,56 @@ export function SourceControlUncommittedSections(props: { isCollapsed={isCollapsed} onToggle={() => props.toggleSection(id)} actions={ - <> -
- {canRevertAll && ( - { - event.stopPropagation() - props.requestDiscardAllInArea(area, discardAllPaths) - }} - disabled={props.isExecutingBulk} - /> - )} - {canStageAll && ( - { - event.stopPropagation() - void props.handleStageAllPaths(stageAllPaths) - }} - disabled={props.isExecutingBulk} - /> - )} - {canUnstageAll && ( - { - event.stopPropagation() - void props.handleUnstagePaths(unstageAllPaths) - }} - disabled={props.isExecutingBulk} - /> - )} -
+
+ {canRevertAll && ( + { + event.stopPropagation() + props.requestDiscardAllInArea(area, discardAllPaths) + }} + disabled={props.isExecutingBulk} + /> + )} + {canStageAll && ( + { + event.stopPropagation() + void props.handleStageAllPaths(stageAllPaths) + }} + disabled={props.isExecutingBulk} + /> + )} + {canUnstageAll && ( + { + event.stopPropagation() + void props.handleUnstagePaths(unstageAllPaths) + }} + disabled={props.isExecutingBulk} + /> + )} {sectionViewAction ? (
} /> {!isCollapsed && ( From 2186a885ddecb47693f1e6c17782f7e34b93560c Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 14 Sep 2026 13:23:34 -0700 Subject: [PATCH 11/43] fix(store): stop two no-op writes from re-running every selector in the app (#20641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(store): stop two no-op writes from re-running every selector in the app zustand bails out of a `set` only when `Object.is(next, state)`. Two updaters that mean "nothing changed" hand it a fresh reference instead: - `setWorkspacePortScanRefreshing` wrote unconditionally — the one action in its file that did; its four siblings all early-return `state`. - `applyGitHubPRRefreshEvent` ended its no-op branch with `: {}`, and `Object.assign({}, state, {})` reproduces every field unchanged while still notifying. ~20 sibling sites in the same store already use `return state`. Both rebuild the root and wake every subscribed selector (~2.2k per the listener census). Renders are unaffected — the selection is unchanged — so the cost is wasted selector evaluation, not commit pressure. The resulting state looks identical either way, which is why it goes unnoticed; both tests therefore count subscriber notifications rather than asserting state. * test(store): use checked initial state in notification regression --------- Co-authored-by: m4air Co-authored-by: Neil --- .../src/store/github/refresh-event-actions.ts | 3 +- .../refresh-event-noop-notification.test.ts | 64 +++++++++++++++++++ .../ui/ui-slice-surface-actions.test.ts | 40 ++++++++++++ .../slices/ui/ui-slice-surface-actions.ts | 7 +- 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/store/github/refresh-event-noop-notification.test.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts diff --git a/src/renderer/src/store/github/refresh-event-actions.ts b/src/renderer/src/store/github/refresh-event-actions.ts index 1e4bccbebd6..803d0168c21 100644 --- a/src/renderer/src/store/github/refresh-event-actions.ts +++ b/src/renderer/src/store/github/refresh-event-actions.ts @@ -229,6 +229,7 @@ export const createRefreshEventActions = ( } } + // Preserve root identity so no-op writes do not notify every store subscriber. return changed ? { prRefreshSequences: capPrRefreshSequences(nextSequences), @@ -237,7 +238,7 @@ export const createRefreshEventActions = ( prCache: nextPRCache, hostedReviewCache: nextHostedReviewCache } - : {} + : s }) if (didUpdatePRCache && event.outcome && event.outcome.kind !== 'upstream-error') { debouncedSaveCache(get()) diff --git a/src/renderer/src/store/github/refresh-event-noop-notification.test.ts b/src/renderer/src/store/github/refresh-event-noop-notification.test.ts new file mode 100644 index 00000000000..ccc94c30b30 --- /dev/null +++ b/src/renderer/src/store/github/refresh-event-noop-notification.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { useAppStore } from '../index' +import { createGitHubSlice } from '../slices/github' +import { createHostedReviewSlice } from '../slices/hosted-review' +import type { AppState } from '../types' + +// @ts-expect-error test window mock +globalThis.window = { api: { gh: { prChecks: vi.fn() }, cache: { setGitHub: vi.fn() } } } + +function createTestStore() { + return create()((...a) => ({ + ...useAppStore.getInitialState(), + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a) + })) +} + +const inFlightEvent = (sequence: number) => ({ + sequence, + aliases: [{ cacheKey: 'repo-1::main', repoId: 'repo-1', repoPath: '/repo', branch: 'main' }], + reason: 'visible' as const, + status: 'in-flight' as const +}) + +describe('applyGitHubPRRefreshEvent no-op updates', () => { + // Why a listener count: zustand bails out only on Object.is(next, state), so a + // `return {}` no-op branch still rebuilds the root and wakes every selector in the + // app. The state looks unchanged afterwards, which is exactly why it goes unnoticed. + it('does not notify subscribers when a stale sequence changes nothing', () => { + const store = createTestStore() + store.getState().applyGitHubPRRefreshEvent(inFlightEvent(4)) + + let notifications = 0 + const unsubscribe = store.subscribe(() => { + notifications += 1 + }) + try { + store.getState().applyGitHubPRRefreshEvent(inFlightEvent(4)) + store.getState().applyGitHubPRRefreshEvent(inFlightEvent(3)) + } finally { + unsubscribe() + } + + expect(notifications).toBe(0) + }) + + it('still notifies when the event advances the sequence', () => { + const store = createTestStore() + store.getState().applyGitHubPRRefreshEvent(inFlightEvent(1)) + + let notifications = 0 + const unsubscribe = store.subscribe(() => { + notifications += 1 + }) + try { + store.getState().applyGitHubPRRefreshEvent(inFlightEvent(2)) + } finally { + unsubscribe() + } + + expect(notifications).toBe(1) + }) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts new file mode 100644 index 00000000000..811b97f3aec --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { useAppStore } from '../../index' + +describe('workspace port-scan surface actions', () => { + beforeEach(() => { + useAppStore.setState({ workspacePortScanRefreshing: false }) + }) + + // Why a listener count and not a state assertion: zustand notifies on identity, so an + // unconditional `set` is invisible in the resulting state yet re-runs every selector. + const countNotifications = (run: () => void): number => { + let notifications = 0 + const unsubscribe = useAppStore.subscribe(() => { + notifications += 1 + }) + try { + run() + } finally { + unsubscribe() + } + return notifications + } + + it('does not notify subscribers when the refreshing flag is unchanged', () => { + const setRefreshing = useAppStore.getState().setWorkspacePortScanRefreshing + + expect(countNotifications(() => setRefreshing(false))).toBe(0) + expect(countNotifications(() => setRefreshing(false))).toBe(0) + expect(useAppStore.getState().workspacePortScanRefreshing).toBe(false) + }) + + it('still notifies once on a real transition, in both directions', () => { + const setRefreshing = useAppStore.getState().setWorkspacePortScanRefreshing + + expect(countNotifications(() => setRefreshing(true))).toBe(1) + expect(useAppStore.getState().workspacePortScanRefreshing).toBe(true) + expect(countNotifications(() => setRefreshing(false))).toBe(1) + expect(useAppStore.getState().workspacePortScanRefreshing).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts index 26aab77d0cb..7157a243d7a 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts @@ -79,8 +79,13 @@ export function createUiSurfaceActions(set: UISliceSet, _get: UISliceGet): Parti : state.workspacePortScan } }), + // Preserve root identity so no-op writes do not notify every store subscriber. setWorkspacePortScanRefreshing: (refreshing) => - set({ workspacePortScanRefreshing: refreshing }), + set((state) => + state.workspacePortScanRefreshing === refreshing + ? state + : { workspacePortScanRefreshing: refreshing } + ), // Why: default true so enabling experimentalPet shows the pet immediately (persisted; "Hide pet" flips it false). petVisible: true, From c3372aeadcccbf22cf7e7e63f98838ddef2879bf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:30:40 -0700 Subject: [PATCH 12/43] fix(ai-vault): bound streamed remote JSONL records (#20700) --- .../ai-vault/remote-session-content-lines.ts | 4 ++- .../remote-session-large-transcripts.test.ts | 29 +++++++++++++++ .../remote-session-stream-lifecycle.test.ts | 20 +++++++++++ .../transcript-stream-lines.test.ts | 35 ++++++++++++++++++- .../native-chat/transcript-stream-lines.ts | 32 ++++++++++++++--- 5 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/main/ai-vault/remote-session-content-lines.ts b/src/main/ai-vault/remote-session-content-lines.ts index d62e8988018..16786704847 100644 --- a/src/main/ai-vault/remote-session-content-lines.ts +++ b/src/main/ai-vault/remote-session-content-lines.ts @@ -4,6 +4,8 @@ import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' export type RemoteSessionContent = string | AsyncIterable +const MAX_REMOTE_SESSION_RECORD_BYTES = 10 * 1024 * 1024 + const REMOTE_CONTENT_YIELD_LINE_COUNT = 200 const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024 @@ -80,7 +82,7 @@ export async function* streamedSessionContentLines( ): AsyncGenerator { let count = 0 let chars = 0 - for await (const record of splitTranscriptStreamLines(bytes)) { + for await (const record of splitTranscriptStreamLines(bytes, MAX_REMOTE_SESSION_RECORD_BYTES)) { throwIfAiVaultScanCancelled(signal) const line = record.line.endsWith('\r') && (record.terminated || signal) diff --git a/src/main/ai-vault/remote-session-large-transcripts.test.ts b/src/main/ai-vault/remote-session-large-transcripts.test.ts index 62d8b8c5ed1..df05700c2cf 100644 --- a/src/main/ai-vault/remote-session-large-transcripts.test.ts +++ b/src/main/ai-vault/remote-session-large-transcripts.test.ts @@ -17,6 +17,35 @@ const jsonl = (rows: unknown[]) => `${rows.map((row) => JSON.stringify(row)).joi const filler = jsonl([{ type: 'irrelevant_event', payload: 'x'.repeat(1024) }]).repeat(11000) describe('large remote history through real relay filesystem', () => { + it('reports an oversized record without losing healthy sessions or publishing a partial session', async () => { + const home = await mkdtemp(join(tmpdir(), 'orca-history-record-limit-')) + try { + const directory = join(home, '.codex', 'sessions') + await mkdir(directory, { recursive: true }) + const metadata = (id: string) => + jsonl([{ type: 'session_meta', payload: { id, cwd: '/repo' } }]) + const badPath = join(directory, 'bad.jsonl') + await writeFile(badPath, metadata('bad') + 'x'.repeat(11 * 1024 * 1024)) + await writeFile(join(directory, 'good.jsonl'), metadata('good')) + const result = await scanRemoteAiVaultSessions({ + provider: createRelayAiVaultFilesystemProvider(), + executionHostId: 'ssh:record-limit', + remoteHome: home, + hostPlatform: platform, + unlimited: true + }) + expect(result.sessions.map((session) => session.sessionId)).toEqual(['good']) + expect(result.issues).toEqual([ + expect.objectContaining({ + path: badPath, + message: 'Session transcript record exceeds 10485760 byte limit' + }) + ]) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('lists a large Codex rollout with middle messages and usage intact', async () => { const home = await mkdtemp(join(tmpdir(), 'orca-history-17744-')) try { diff --git a/src/main/ai-vault/remote-session-stream-lifecycle.test.ts b/src/main/ai-vault/remote-session-stream-lifecycle.test.ts index 46668e3574e..2b8e95ea2a4 100644 --- a/src/main/ai-vault/remote-session-stream-lifecycle.test.ts +++ b/src/main/ai-vault/remote-session-stream-lifecycle.test.ts @@ -4,6 +4,26 @@ import { readStreamedSessionDocument } from './session-document-stream' import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' describe('stream lifetime and retained document work', () => { + it('aborts a newline-free record at the byte ceiling and closes the source', async () => { + let closed = false + let reads = 0 + async function* bytes() { + const chunk = Buffer.alloc(1024 * 1024, 'x') + try { + for (; reads < 100;) { + reads++ + yield chunk + } + } finally { + closed = true + } + } + const lines = streamedSessionContentLines(bytes()) + await expect(lines.next()).rejects.toThrow('record exceeds 10485760 byte limit') + expect(reads).toBe(11) + expect(closed).toBe(true) + }) + it('releases the source when a line consumer finishes early', async () => { let closed = false async function* bytes() { diff --git a/src/main/native-chat/transcript-stream-lines.test.ts b/src/main/native-chat/transcript-stream-lines.test.ts index 48feedfbd98..25ac54d904a 100644 --- a/src/main/native-chat/transcript-stream-lines.test.ts +++ b/src/main/native-chat/transcript-stream-lines.test.ts @@ -1,6 +1,6 @@ import { Readable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import { decodeTranscriptStream } from './transcript-stream-lines' +import { decodeTranscriptStream, splitTranscriptStreamLines } from './transcript-stream-lines' const decode = (line: string, id: string) => ({ id, @@ -171,3 +171,36 @@ describe('decodeTranscriptStream', () => { expect(result.consumedBytes).toBe(Buffer.byteLength(complete, 'utf8')) }) }) + +describe('bounded transcript records', () => { + async function collect(chunks: (Buffer | string)[], limit: number) { + const records: string[] = [] + for await (const record of splitTranscriptStreamLines(Readable.from(chunks), limit)) { + records.push(record.line) + } + return records + } + + it.each(['', '\n', '\nnext\n'])('rejects an oversized record ending in %j', async (ending) => { + await expect(collect(['1234', `5${ending}`], 4)).rejects.toThrow('record exceeds 4 byte limit') + await expect(collect([`12345${ending}`], 4)).rejects.toThrow('record exceeds 4 byte limit') + }) + + it('resets the byte budget per record and accepts the exact limit', async () => { + expect(await collect(['1234\n123', '4\n1234'], 4)).toEqual(['1234', '1234', '1234']) + }) + + it('counts UTF-8 bytes across split codepoints', async () => { + const bytes = Buffer.from('😀é') + const chunks = [bytes.subarray(0, 2), bytes.subarray(2, 5), bytes.subarray(5)] + expect((await collect(chunks, 6))[0]).toBe('😀é') + await expect(collect(chunks, 5)).rejects.toThrow('record exceeds 5 byte limit') + expect((await collect(['\ud83d', '\ude00'], 4))[0]).toBe('😀') + }) + + it('checks the decoder tail before emitting it', async () => { + await expect(collect([Buffer.from([0x61, 0xf0, 0x9f])], 3)).rejects.toThrow( + 'record exceeds 3 byte limit' + ) + }) +}) diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts index 4a58b4e180f..ce22822b76c 100644 --- a/src/main/native-chat/transcript-stream-lines.ts +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -42,12 +42,13 @@ export async function decodeTranscriptStream( type TranscriptLine = { line: string; byteLength: number; terminated: boolean } export async function* splitTranscriptStreamLines( - stream: AsyncIterable + stream: AsyncIterable, + maxRecordBytes = Infinity ): AsyncGenerator { let records: TranscriptLine[] = [] const framer = createTranscriptLineFramer((line, byteLength, terminated) => { records.push({ line, byteLength, terminated }) - }) + }, maxRecordBytes) for await (const chunk of stream) { framer.write(chunk) for (const record of records) { @@ -63,10 +64,12 @@ export async function* splitTranscriptStreamLines( /** Frame chunks synchronously so native decoding avoids a promise per record. */ function createTranscriptLineFramer( - emit: (line: string, byteLength: number, terminated: boolean) => void + emit: (line: string, byteLength: number, terminated: boolean) => void, + maxRecordBytes = Infinity ): { write(chunk: Buffer | string): void; end(): void } { const decoder = new StringDecoder('utf8') let pending: string[] = [] + let pendingBytes = 0 return { write, end } function write(chunk: Buffer | string): void { @@ -75,23 +78,44 @@ function createTranscriptLineFramer( let newlineIndex = text.indexOf('\n') while (newlineIndex !== -1) { let segment = text.slice(lineStart, newlineIndex + 1) + checkRecordBytes(segment.slice(0, -1)) if (pending.length > 0) { pending.push(segment) segment = pending.join('') pending = [] } + pendingBytes = 0 emit(segment.slice(0, -1), Buffer.byteLength(segment, 'utf8'), true) lineStart = newlineIndex + 1 newlineIndex = text.indexOf('\n', lineStart) } if (lineStart < text.length) { - pending.push(text.slice(lineStart)) + const segment = text.slice(lineStart) + checkRecordBytes(segment) + pending.push(segment) + } + } + + function checkRecordBytes(segment: string): void { + if (maxRecordBytes === Infinity) { + return + } + pendingBytes += Buffer.byteLength(segment, 'utf8') + const previous = pending.at(-1) + // Separately encoded surrogate halves become one four-byte codepoint when joined. + if (previous && /[\uD800-\uDBFF]$/.test(previous) && /^[\uDC00-\uDFFF]/.test(segment)) { + pendingBytes -= 2 + } + if (pendingBytes > maxRecordBytes) { + pending = [] + throw new Error(`Session transcript record exceeds ${maxRecordBytes} byte limit`) } } function end(): void { const tail = decoder.end() if (tail) { + checkRecordBytes(tail) pending.push(tail) } const line = pending.join('') From 1ba98015745c8db70ebd56ba10da9d6e8fe263c7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:33:04 -0700 Subject: [PATCH 13/43] fix(ci): stop hourly versions dropping below a tagged or already-shipped build (#20699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): stop hourly versions dropping below a tagged or already-shipped build Hourly/daily/adhoc based their X.Y.Z on GitHub releases, not git tags. When v1.4.202 was tagged and then its GitHub release vanished, the next hourlies shipped as 1.4.202-hourly — below both stable 1.4.202 and the 1.4.203-hourly builds already installed, so electron-updater stopped offering updates. Read main's v* tags and already-published channel tags instead. * docs(ci): record that 1.4.202's release was unpublished for a bug The leftover tag is what hourly must still honor; this was not a failed cut. --- .github/workflows/adhoc-mac-build.yml | 13 +++++--- .github/workflows/daily-mac-build.yml | 22 +++++++------ .github/workflows/hourly-mac-build.yml | 32 ++++++++++++------- config/scripts/dev-channel-base-version.mjs | 7 ++-- .../scripts/dev-channel-base-version.test.mjs | 21 ++++++++++++ config/scripts/hourly-build-version.test.mjs | 24 ++++++++++++++ .../workflow-ref-mirror-case-safety.test.mjs | 19 +++++++++++ 7 files changed, 110 insertions(+), 28 deletions(-) diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 53501add289..4f667bf2778 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -235,11 +235,14 @@ jobs: fi done echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - # Why the main repo's tags: package.json on a branch is as stale as the - # main it forked from, and stable patches never merge back into it. - published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ - --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ - --json tagName --jq '.[].tagName' || true)" + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag, which still owns that number. + # Releases-only let adhoc sit on a number already taken, so the updater + # would not install it. Empty on failure — the script then falls back + # to package.json. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \ node config/scripts/adhoc-build-version.mjs \ >"$RUNNER_TEMP/adhoc-identity.txt" diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index a758d8db3a1..41b87526fea 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -90,7 +90,7 @@ jobs: uses: actions/checkout@v6 with: ref: main - # Version helpers only read HEAD; published versions come from the release API. + # Version helpers only read HEAD; published versions come from git tags. fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the daily repo through a minted App token passed by env. @@ -209,17 +209,21 @@ jobs: # number free", where a stranded draft still holds one. names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \ --jq '.[].name // empty')" - # Why the main repo's tags decide the base version rather than - # package.json: main's version only moves on `release:` commits, and - # stable patches are cut from release branches that never merge back, so - # package.json can sit several patches behind what users are running. A + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag. That dragged hourlies backwards so + # electron-updater stopped offering them; dailies would do the same. A # separate token because GH_TOKEN above is the App's, scoped to the # daily repo. Empty on failure — the script then falls back to # package.json, which is stale but never wrong enough to fail a build. - published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ - --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ - --json tagName --jq '.[].tagName' || true)" - echo "Highest published tag seen: $(head -1 <<<"$published")" + main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" + # Already-shipped channel tags are a second floor so unpublishing a + # buggy main release cannot drag this series below a daily already out. + channel_tags="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName \ + --jq '.[].tagName' || true)" + published="$main_tags"$'\n'"$channel_tags" + echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags" ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \ node config/scripts/daily-build-version.mjs \ >"$RUNNER_TEMP/daily-identity.txt" diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index b4bdd803a6a..1aed485a666 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -137,7 +137,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.head_sha }} - # Version helpers only read HEAD; published versions come from the release API. + # Version helpers only read HEAD; published versions come from git tags. fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the hourly repo through a minted App token passed by env. @@ -213,17 +213,25 @@ jobs: # number free", where a stranded draft still holds one. names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \ --jq '.[].name // empty')" - # Why the main repo's tags decide the base version rather than - # package.json: main's version only moves on `release:` commits, and - # stable patches are cut from release branches that never merge back, so - # package.json can sit several patches behind what users are running. A - # separate token because GH_TOKEN above is the App's, scoped to the - # hourly repo. Empty on failure — the script then falls back to - # package.json, which is stale but never wrong enough to fail a build. - published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ - --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ - --json tagName --jq '.[].tagName' || true)" - echo "Highest published tag seen: $(head -1 <<<"$published")" + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag. On 2026-09-14 we deleted v1.4.202's + # release for a bug; hourlies had already climbed to 1.4.203, then + # `gh release list` fell back to v1.4.201 and the next hourlies shipped + # as 1.4.202-hourly — which electron-updater will not install over + # 1.4.203-hourly or over the still-tagged 1.4.202. A separate token + # because GH_TOKEN above is the App's, scoped to the hourly repo. Empty + # on failure — the script then falls back to package.json, which is + # stale but never wrong enough to fail a build. + main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" + # Already-shipped channel tags are a second floor: even if main's tag + # list is empty this run, a 1.4.203-hourly already out must not be + # followed by a 1.4.202-hourly. + channel_tags="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName \ + --jq '.[].tagName' || true)" + published="$main_tags"$'\n'"$channel_tags" + echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags" ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \ node config/scripts/hourly-build-version.mjs \ >"$RUNNER_TEMP/hourly-identity.txt" diff --git a/config/scripts/dev-channel-base-version.mjs b/config/scripts/dev-channel-base-version.mjs index 62a28c6a374..074af9ec3d9 100644 --- a/config/scripts/dev-channel-base-version.mjs +++ b/config/scripts/dev-channel-base-version.mjs @@ -25,8 +25,11 @@ function compareTriples(a, b) { * 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and * 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while * carrying code newer than 1.4.167, and sorted *below* the stable their user was - * already running. Published tags are the only honest answer to "what number is - * taken"; package.json is a floor, not a source of truth. + * already running. Git tags (not GitHub releases) are the honest answer to "what + * number is taken": unpublishing a buggy cut deletes the GitHub release and + * leaves the tag, which still owns that number. Channel tags (`1.4.203-hourly.*`) + * are a second floor so that unpublish cannot drag the series backwards. + * package.json is a floor, not a source of truth. */ export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) { const fromPackage = parseVersionTriple(packageVersion) diff --git a/config/scripts/dev-channel-base-version.test.mjs b/config/scripts/dev-channel-base-version.test.mjs index d2631eff0e9..00c13f6817a 100644 --- a/config/scripts/dev-channel-base-version.test.mjs +++ b/config/scripts/dev-channel-base-version.test.mjs @@ -39,6 +39,27 @@ describe('dev channel base version', () => { ) }) + // Why tags rather than GitHub releases: unpublishing a buggy cut deletes the + // GitHub release and leaves the tag. Releases-only then treated 1.4.202 as + // free, so hourlies sat on 1.4.202-hourly and sorted below that tagged stable. + it('climbs past a tagged stable that has no GitHub release', () => { + expect(resolveDevChannelBaseVersion('1.4.197', ['v1.4.201', 'v1.4.202'])).toBe('1.4.203') + }) + + // 2026-09-14: v1.4.202's GitHub release was deleted for a bug after hourlies + // had already shipped as 1.4.203. Without the channel tags as a floor, the + // next hourlies would have been 1.4.202-hourly, which electron-updater will + // not install over 1.4.203-hourly. + it('does not drop below an already-published channel version', () => { + expect( + resolveDevChannelBaseVersion('1.4.197', [ + 'v1.4.201', + 'v1.4.202-hourly.202609141912', + 'v1.4.203-hourly.202609140417' + ]) + ).toBe('1.4.203') + }) + it('treats package.json as a floor when it leads the tags', () => { expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0') }) diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs index 08fc9d28b81..7438b16acbd 100644 --- a/config/scripts/hourly-build-version.test.mjs +++ b/config/scripts/hourly-build-version.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createHourlyBuildVersion, formatHourlyReleaseName, + getHourlyBuildIdentity, nextHourlyBuildNumber } from './hourly-build-version.mjs' import { compareAppVersions } from '../../src/shared/app-version' @@ -120,3 +121,26 @@ describe('nextHourlyBuildNumber', () => { expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1) }) }) + +describe('getHourlyBuildIdentity', () => { + // 2026-09-14: v1.4.202's GitHub release was deleted for a bug after hourlies + // had climbed to 1.4.203. Passing the leftover tag and the already-shipped + // hourly keeps the next build on 1.4.203 so electron-updater will still + // install it. + it('stays on the already-shipped hourly base after a buggy main release is unpublished', () => { + const identity = getHourlyBuildIdentity(new Date('2026-09-14T20:00:00Z'), { + publishedVersions: [ + 'v1.4.201', + 'v1.4.202', + 'v1.4.202-hourly.202609141912', + 'v1.4.203-hourly.202609140417' + ], + releaseNames: [ + '1.4.202 • 14 • Sep 14, 12:12PM • 875b86d', + '1.4.203 • 04 • Sep 13, 9:17PM • 2ce252f' + ] + }) + expect(identity.version).toBe('1.4.203-hourly.202609142000') + expect(identity.buildNumber).toBe(5) + }) +}) diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index 497a58653a0..8ebb2fced9d 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -30,6 +30,25 @@ describe('ref-mirroring vet steps', () => { ).toBe(true) }) + // Why matching-refs rather than `gh release list` on the main repo: a tagged + // stable still owns its number after its GitHub release is unpublished for a + // bug, and that unpublish must not drag the channel backwards. + it.each(['daily', 'hourly', 'adhoc'])( + '%s versions from git tags, not main GitHub releases', + (channel) => { + const step = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[ + `build-${channel}-mac` + ].steps.find((candidate) => candidate.name === `Compute ${channel} version`) + expect(step.run).toContain('git/matching-refs/tags/v') + expect(step.run).not.toMatch( + /gh release list[\s\S]*--repo "\$GITHUB_REPOSITORY"[\s\S]*--json tagName/ + ) + if (channel !== 'adhoc') { + expect(step.run).toContain('channel_tags=') + } + } + ) + it('retains release-cut history for version reservation and retry ancestry', () => { const checkout = readWorkflow('.github/workflows/release-cut.yml').jobs.cut.steps.find( (step) => step.uses === 'actions/checkout@v6' From dede24df46a80ced43b7a732d820f768cbbd72e7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:33:19 -0700 Subject: [PATCH 14/43] fix(store): preserve state identity for no-op updater branches (#20703) --- .../tab-drag-preview-activation.test.ts | 29 +++- .../tab-group/tab-drag-preview-activation.ts | 36 ++++- .../src/store/github/project-cache.ts | 4 +- .../store/github/pull-request-execution.ts | 4 +- .../github/work-item-mutation-actions.ts | 2 +- .../slices/browser/browser-host-actions.ts | 4 +- .../slices/browser/browser-host-state.ts | 2 +- .../browser/browser-hydration-actions.ts | 2 +- .../browser/browser-profile-import-actions.ts | 8 +- .../store/slices/commit-message-generation.ts | 4 +- .../store/slices/diff-comment-persistence.ts | 18 +-- .../slices/empty-update-notifications.test.ts | 81 ++++++++++ ...hub-pr-branch-linked-pr-divergence.test.ts | 4 + .../github-work-item-cache-identity.test.ts | 9 ++ .../slices/hosted-review-cache-race.test.ts | 4 + .../src/store/slices/hosted-review.ts | 2 +- .../store/slices/jira-issue-patch-action.ts | 2 +- .../linear/linear-invalidation-actions.ts | 2 +- .../store/slices/pull-request-generation.ts | 4 +- .../src/store/slices/sparse-presets.ts | 2 +- .../store/slices/tabs/tabs-create-actions.ts | 2 +- .../store/slices/tabs/tabs-drop-actions.ts | 8 +- .../store/slices/tabs/tabs-focus-actions.ts | 2 +- .../store/slices/tabs/tabs-label-actions.ts | 35 +++-- .../store/slices/tabs/tabs-move-actions.ts | 6 +- .../create/pending-worktree-creation.ts | 8 +- .../metadata/hosted-review-link-mutation.ts | 2 +- .../metadata/update-worktree-meta.ts | 4 +- .../metadata/update-worktrees-meta.ts | 2 +- .../session/migrate-worktree-identity.ts | 5 +- .../worktrees/session/set-active-worktree.ts | 6 +- .../worktree-no-op-notifications.test.ts | 64 ++++++++ .../session/worktree-slice-lookups.ts | 6 +- .../session/worktree-unread-activity.ts | 4 +- .../session/worktree-visit-recency.ts | 8 +- .../teardown/worktree-delete-state.ts | 6 +- .../terminal-disowned-pty-sources.ts | 2 +- .../terminals/terminal-ephemeral-state.ts | 16 +- .../store/terminals/terminal-layout-state.ts | 2 +- .../terminal-no-op-subscriber.test.ts | 146 ++++++++++++++++++ .../store/terminals/terminal-restart-state.ts | 10 +- .../terminals/terminal-startup-queues.ts | 2 +- .../terminals/terminal-unverified-pty-loss.ts | 2 +- 43 files changed, 472 insertions(+), 99 deletions(-) create mode 100644 src/renderer/src/store/slices/empty-update-notifications.test.ts create mode 100644 src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts index 019b3e297ee..1269ade41a4 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import { applyDragPreviewTab, captureTabDragActivationSnapshot, - restoreTabDragActivationSnapshot + restoreTabDragActivationSnapshot, + restoreSourceGroupActiveTabAfterCrossGroupDrop } from './tab-drag-preview-activation' const WT = 'wt-preview-restore' @@ -59,6 +60,30 @@ describe('restoreTabDragActivationSnapshot', () => { }) }) + it('does not publish repeated preview and restore actions', () => { + const snapshot = captureTabDragActivationSnapshot(WT) + const subscriber = vi.fn() + const unsubscribe = useAppStore.subscribe(subscriber) + try { + applyDragPreviewTab({ + worktreeId: WT, + groupId: 'group-1', + tabId: 'tab-1', + activeGroupId: 'group-1' + }) + restoreTabDragActivationSnapshot(WT, snapshot) + restoreSourceGroupActiveTabAfterCrossGroupDrop({ + worktreeId: WT, + snapshot, + sourceGroupId: 'group-1', + movedTabId: 'tab-2' + }) + expect(subscriber).not.toHaveBeenCalled() + } finally { + unsubscribe() + } + }) + it('restores active-surface fields after a drag preview is cancelled', () => { const snapshot = captureTabDragActivationSnapshot(WT) diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts index a0060271c99..31726672db2 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts @@ -30,6 +30,14 @@ function previewActiveSurfacePatch( }) if (unifiedTab.contentType === 'terminal') { + if ( + state.activeTabType === 'terminal' && + state.activeTabTypeByWorktree[worktreeId] === 'terminal' && + state.activeTabId === unifiedTab.entityId && + state.activeTabIdByWorktree[worktreeId] === unifiedTab.entityId + ) { + return {} + } return { activeTabId: unifiedTab.entityId, activeTabType: 'terminal', @@ -41,6 +49,14 @@ function previewActiveSurfacePatch( } } if (unifiedTab.contentType === 'browser') { + if ( + state.activeTabType === 'browser' && + state.activeTabTypeByWorktree[worktreeId] === 'browser' && + state.activeBrowserTabId === unifiedTab.entityId && + state.activeBrowserTabIdByWorktree[worktreeId] === unifiedTab.entityId + ) { + return {} + } return { activeBrowserTabId: unifiedTab.entityId, activeTabType: 'browser', @@ -52,11 +68,25 @@ function previewActiveSurfacePatch( } } if (unifiedTab.contentType === 'simulator') { + if ( + state.activeTabType === 'simulator' && + state.activeTabTypeByWorktree[worktreeId] === 'simulator' + ) { + return {} + } return { activeTabType: 'simulator', activeTabTypeByWorktree: nextActiveTabTypeByWorktree('simulator') } } + if ( + state.activeTabType === 'editor' && + state.activeTabTypeByWorktree[worktreeId] === 'editor' && + state.activeFileId === unifiedTab.entityId && + state.activeFileIdByWorktree[worktreeId] === unifiedTab.entityId + ) { + return {} + } return { activeFileId: unifiedTab.entityId, activeTabType: 'editor', @@ -95,7 +125,7 @@ export function applyDragPreviewTab({ const focusUnchanged = (state.activeGroupIdByWorktree[worktreeId] ?? null) === activeGroupId const surfacePatch = previewActiveSurfacePatch(state, worktreeId, groupId, tabId) if (groupUnchanged && focusUnchanged) { - return Object.keys(surfacePatch).length > 0 ? surfacePatch : {} + return Object.keys(surfacePatch).length > 0 ? surfacePatch : state } const next: Partial = { ...surfacePatch } @@ -162,7 +192,7 @@ export function restoreTabDragActivationSnapshot( } if (Object.keys(next).length === 0) { - return {} + return state } return next @@ -191,7 +221,7 @@ export function restoreSourceGroupActiveTabAfterCrossGroupDrop({ const groups = state.groupsByWorktree[worktreeId] ?? [] const sourceGroup = groups.find((group) => group.id === sourceGroupId) if (!sourceGroup || sourceGroup.activeTabId === preDragActiveTabId) { - return {} + return state } return { groupsByWorktree: { diff --git a/src/renderer/src/store/github/project-cache.ts b/src/renderer/src/store/github/project-cache.ts index df8ac361025..eb296633201 100644 --- a/src/renderer/src/store/github/project-cache.ts +++ b/src/renderer/src/store/github/project-cache.ts @@ -76,11 +76,11 @@ export function applyRowPatch( set((s) => { const entry = s.projectViewCache[cacheKey] if (!entry?.data) { - return {} + return s } const rowIndex = entry.data.rows.findIndex((r) => r.id === rowId) if (rowIndex === -1) { - return {} + return s } const rows = [...entry.data.rows] rows[rowIndex] = nextRow diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index 72069802064..553ef5c1e13 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -153,7 +153,7 @@ export function startPullRequestLookup(args: { // Why: unlinking a PR mid exact-linked-PR-lookup must stop the older result from restoring the manual link UI. if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) { skippedStaleLinkedPRLookup = true - return {} + return s } const updates = setGitHubPRResultCaches(s, { prCacheKey: cacheKey, @@ -174,7 +174,7 @@ export function startPullRequestLookup(args: { requestStartedEntry: requestStartedHostedReviewEntry }) didUpdatePRCache = updates.prCache !== undefined - return updates + return updates.prCache || updates.hostedReviewCache ? updates : s }) if (skippedStaleLinkedPRLookup) { return null diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts index e1d02a09d3b..97a6d3b66f3 100644 --- a/src/renderer/src/store/github/work-item-mutation-actions.ts +++ b/src/renderer/src/store/github/work-item-mutation-actions.ts @@ -45,7 +45,7 @@ export const createWorkItemMutationActions = ( nextCache[key] = { ...entry, data: updatedItems } changed = true } - return changed ? { workItemsCache: nextCache } : {} + return changed ? { workItemsCache: nextCache } : s }) }, diff --git a/src/renderer/src/store/slices/browser/browser-host-actions.ts b/src/renderer/src/store/slices/browser/browser-host-actions.ts index 2977717d38c..193dfdafe60 100644 --- a/src/renderer/src/store/slices/browser/browser-host-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-host-actions.ts @@ -24,7 +24,7 @@ export function createBrowserHostActions( closes, Date.now() ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : s }) }, @@ -34,7 +34,7 @@ export function createBrowserHostActions( s.clientHostedBrowserCloseIntentsByEnvironment, { environmentId, browserPageIds, now: Date.now() } ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : s }) }, diff --git a/src/renderer/src/store/slices/browser/browser-host-state.ts b/src/renderer/src/store/slices/browser/browser-host-state.ts index 1b4b3a70e9a..7ee75ac54a1 100644 --- a/src/renderer/src/store/slices/browser/browser-host-state.ts +++ b/src/renderer/src/store/slices/browser/browser-host-state.ts @@ -153,7 +153,7 @@ export function browserImportStateForHostUpdate( hostId: ExecutionHostId, browserSessionImportState: BrowserSlice['browserSessionImportState'] ): Partial { - return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {} + return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : state } export function getFallbackTabTypeForWorktree( diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts index a2ed3f5bbb1..13beee05872 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -257,7 +257,7 @@ export function createBrowserHydrationActions( } } } - return {} + return s }) } } diff --git a/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts index ec03690bf8e..9b79ec9adbc 100644 --- a/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts @@ -133,13 +133,13 @@ export function createBrowserProfileImportActions( set((s) => getBrowserSettingsHostId(s) === hostId ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost } - : {} + : s ) } catch { set((s) => getBrowserSettingsHostId(s) === hostId ? { detectedBrowsers: [], detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} + : s ) } return @@ -161,11 +161,11 @@ export function createBrowserProfileImportActions( detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} + : s ) } catch { /* best-effort — empty list is acceptable fallback */ - set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {})) + set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : s)) } } } diff --git a/src/renderer/src/store/slices/commit-message-generation.ts b/src/renderer/src/store/slices/commit-message-generation.ts index e361958932c..4e7ef3e722d 100644 --- a/src/renderer/src/store/slices/commit-message-generation.ts +++ b/src/renderer/src/store/slices/commit-message-generation.ts @@ -160,7 +160,7 @@ export const createCommitMessageGenerationSlice: StateCreator< set((state) => { const nextRecord = updater(state.commitMessageGenerationRecords[key] ?? null) if (!nextRecord) { - return {} + return state } return { commitMessageGenerationRecords: { @@ -184,6 +184,6 @@ export const createCommitMessageGenerationSlice: StateCreator< changed = true } } - return changed ? { commitMessageGenerationRecords: nextRecords } : {} + return changed ? { commitMessageGenerationRecords: nextRecords } : state }) }) diff --git a/src/renderer/src/store/slices/diff-comment-persistence.ts b/src/renderer/src/store/slices/diff-comment-persistence.ts index 92e1eb9ea9f..4d28d7b7f1d 100644 --- a/src/renderer/src/store/slices/diff-comment-persistence.ts +++ b/src/renderer/src/store/slices/diff-comment-persistence.ts @@ -239,13 +239,13 @@ export function mutateDiffComments( if (scope?.type === 'folder') { const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId) if (!target) { - return {} + return s } folderExecutionHostId = getExecutionHostIdForFolderWorkspace(s, scope.folderWorkspaceId) previous = target.diffComments const computed = mutate(previous ?? []) if (computed === null) { - return {} + return s } next = computed return { @@ -256,16 +256,16 @@ export function mutateDiffComments( } const repoList = s.worktreesByRepo[repoId] if (!repoList) { - return {} + return s } const target = repoList.find((w) => w.id === worktreeId) if (!target) { - return {} + return s } previous = target.diffComments const computed = mutate(previous ?? []) if (computed === null) { - return {} + return s } next = computed const nextList: Worktree[] = repoList.map((w) => @@ -293,7 +293,7 @@ function rollback( if (scope?.type === 'folder') { const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId, folderExecutionHostId) if (!target || target.diffComments !== expectedCurrent) { - return {} + return s } return { folderWorkspaces: s.folderWorkspaces.map((workspace) => @@ -303,16 +303,16 @@ function rollback( } const repoList = s.worktreesByRepo[repoId] if (!repoList) { - return {} + return s } const target = repoList.find((w) => w.id === worktreeId) // Why: worktree gone since the mutation; bail before remapping so we don't allocate a new array identity and fire spurious notifications. if (!target) { - return {} + return s } // Why: only roll back if no later mutation replaced the array, else our stale `previous` would erase newer state. if (target.diffComments !== expectedCurrent) { - return {} + return s } const nextList: Worktree[] = repoList.map((w) => w.id === worktreeId ? { ...w, diffComments: previous } : w diff --git a/src/renderer/src/store/slices/empty-update-notifications.test.ts b/src/renderer/src/store/slices/empty-update-notifications.test.ts new file mode 100644 index 00000000000..692fa50b7d4 --- /dev/null +++ b/src/renderer/src/store/slices/empty-update-notifications.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import { createTabsSliceMockApi } from './tabs-slice-test-harness' +import { browserImportStateForHostUpdate } from './browser/browser-host-state' +import { mutateDiffComments } from './diff-comment-persistence' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +createTabsSliceMockApi() + +describe('empty store updates', () => { + it('does not notify for missing tab actions', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.setTabLabel('missing', 'label') + before.setTabCustomLabel('missing', 'label') + before.setUnifiedTabColor('missing', null) + before.setTabViewMode('missing', 'chat') + before.toggleTabViewMode('missing') + before.pinTab('missing') + before.unpinTab('missing') + before.reorderUnifiedTabs('missing', []) + before.moveUnifiedTabToGroup('missing', 'missing') + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it('does not notify for unchanged labels but publishes changed labels', () => { + const store = createTestStore() + const tab = store + .getState() + .createUnifiedTab('folder-workspace', 'terminal', { label: 'label' }) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.setTabLabel(tab.id, 'label') + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + + before.setTabLabel(tab.id, 'new label') + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState().getTab(tab.id)?.label).toBe('new label') + }) + + it('does not notify for rejected generation updates or empty pruning', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.updateCommitMessageGenerationRecord('missing', () => null) + before.updatePullRequestGenerationRecord('missing', () => null) + before.pruneCommitMessageGenerationRecords(new Set()) + before.prunePullRequestGenerationRecords(new Set()) + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it('does not notify for absent Jira issues, browser pages or diff comments', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.patchJiraIssue('MISSING-1', {}) + before.patchLinearIssue('missing', {}) + before.switchBrowserTabProfile('missing', null, 'persist:missing') + before.recordClientHostedBrowserCloseIntents([]) + before.clearClientHostedBrowserCloseIntents('missing', []) + mutateDiffComments(store.setState, 'missing', () => null) + store.setState((state) => browserImportStateForHostUpdate(state, 'runtime:other', null)) + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts index 418b6648fa0..8b42bf3e56f 100644 --- a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts @@ -86,6 +86,8 @@ describe('createGitHubSlice.fetchPRForBranch', () => { hostedReviewCache: {}, prCache: {} } as unknown as Partial) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) resolveRefresh({ kind: 'found', pr: makePR({ number: 12, title: 'Stale exact linked PR' }), @@ -93,6 +95,8 @@ describe('createGitHubSlice.fetchPRForBranch', () => { }) await expect(request).resolves.toBeNull() + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() expect(store.getState().prCache[`${repoId}::${branch}`]).toBeUndefined() expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toBeUndefined() }) diff --git a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts index ef22da072d1..ba741de5cfb 100644 --- a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts +++ b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts @@ -15,6 +15,15 @@ describe('createGitHubSlice.patchWorkItem', () => { resetRemoteRuntimeMocks() }) + it('does not notify when a patch has no matching cached work item', () => { + const store = createTestStore() + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + store.getState().patchWorkItem('pr:missing', { title: 'Missing' }, 'repo-1') + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() + }) + it('can scope patches to one repo when different repos have the same work-item id', () => { const store = createTestStore() const repoOneItem = { diff --git a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts index 59e253e1823..16d765fe0d5 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts @@ -112,10 +112,14 @@ describe('hosted review cache race protection', () => { } } }) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) vi.setSystemTime(300) resolveFetch(olderReview) await expect(request).resolves.toEqual(olderReview) + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() expect(store.getState().hostedReviewCache[cacheKey]).toEqual({ data: newerReview, fetchedAt: 200, diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 53cdb00db55..cad350d986b 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -234,7 +234,7 @@ export const createHostedReviewSlice: StateCreator { const nextRecord = updater(state.pullRequestGenerationRecords[key] ?? null) if (!nextRecord) { - return {} + return state } return { pullRequestGenerationRecords: { @@ -312,6 +312,6 @@ export const createPullRequestGenerationSlice: StateCreator< changed = true } } - return changed ? { pullRequestGenerationRecords: nextRecords } : {} + return changed ? { pullRequestGenerationRecords: nextRecords } : state }) }) diff --git a/src/renderer/src/store/slices/sparse-presets.ts b/src/renderer/src/store/slices/sparse-presets.ts index b9e763bf72e..6a4a2d68245 100644 --- a/src/renderer/src/store/slices/sparse-presets.ts +++ b/src/renderer/src/store/slices/sparse-presets.ts @@ -145,7 +145,7 @@ export const createSparsePresetsSlice: StateCreator { const existing = s.sparsePresetsByRepo[args.repoId] if (existing === undefined) { - return {} + return s } const without = existing.filter((preset) => preset.id !== saved.id) return { diff --git a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts index b4f1d611b2f..f8736ed250b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts @@ -120,7 +120,7 @@ export function createTabsCreateActions( target.sourceGroupId ) if (!sourceGroup) { - return {} + return state } const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] const currentGroups = state.groupsByWorktree[worktreeId] ?? [] diff --git a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts index 2cadf76365e..612bee0b538 100644 --- a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts @@ -25,19 +25,19 @@ export function createTabsDropActions( const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) const foundTarget = findGroupAndWorktree(state.groupsByWorktree, target.groupId) if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} + return state } const { tab, worktreeId } = foundTab const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) const targetGroup = foundTarget.group if (!sourceGroup) { - return {} + return state } const isSplitDrop = Boolean(target.splitDirection) if (!isSplitDrop && tab.groupId === target.groupId) { - return {} + return state } const layout = state.layoutByWorktree[worktreeId] if ( @@ -51,7 +51,7 @@ export function createTabsDropActions( }) ) { // Why: dropping a group's last tab on its own/sibling matching edge only makes a transient column that immediately collapses. - return {} + return state } moved = true diff --git a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts index 39a0dd38858..6e4134c6a1b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts @@ -53,7 +53,7 @@ export function createTabsFocusActions( found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) } if (!found) { - return {} + return state } const { tab, worktreeId } = found // Why: activating a terminal tab dismisses its tab-level bell — the user has moved their eyes here. diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 42b8e3d7163..3ee06aa105c 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -50,7 +50,7 @@ export function createTabsLabelActions( } } } - return {} + return state }) if (reordered && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') @@ -58,17 +58,24 @@ export function createTabsLabelActions( }, setTabLabel: (tabId, label) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? state) }, setTabViewMode: (tabId, mode) => { - set((state) => ({ - ...patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }), - // Why the row too: viewMode is declared on both types and host-sync - // already writes it to the row. Only these local toggles skipped it, so - // readers had to OR the two indices to find out who owns the surface. - ...patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) - })) + set((state) => { + const tabPatch = patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) + const rowPatch = patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) + if (!tabPatch && !rowPatch.tabsByWorktree) { + return state + } + return { + ...tabPatch, + // Why the row too: viewMode is declared on both types and host-sync + // already writes it to the row. Only these local toggles skipped it, so + // readers had to OR the two indices to find out who owns the surface. + ...rowPatch + } + }) mirrorTabViewModeToHost(get(), tabId, mode) }, @@ -81,7 +88,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } // Why: viewMode defaults to 'terminal' for legacy/missing, so the first toggle flips to 'chat'. const fromMode: 'terminal' | 'chat' = found.tab.viewMode === 'chat' ? 'chat' : 'terminal' @@ -111,7 +118,7 @@ export function createTabsLabelActions( setTabCustomLabel: (tabId, label, opts) => { const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? state) if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -119,7 +126,7 @@ export function createTabsLabelActions( setUnifiedTabColor: (tabId, color) => { const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? state) if (exists) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -130,7 +137,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } const { tab, worktreeId } = found const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => @@ -168,7 +175,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } const { tab, worktreeId } = found const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts index 4c651f613ac..8e8a51f5c0c 100644 --- a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -22,16 +22,16 @@ export function createTabsMoveActions( const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) const foundTarget = findGroupAndWorktree(state.groupsByWorktree, targetGroupId) if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} + return state } const { tab, worktreeId } = foundTab if (tab.groupId === targetGroupId) { - return {} + return state } const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) const targetGroup = foundTarget.group if (!sourceGroup) { - return {} + return state } moved = true diff --git a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts index 28530219cce..435eadec4ec 100644 --- a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts +++ b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts @@ -23,14 +23,14 @@ export function createUpdatePendingWorktreeCreation( set((s) => { const entry = s.pendingWorktreeCreations[creationId] if (!entry) { - return {} + return s } // Why: the main process re-emits the same phase; skip no-op writes so the strip and panel don't re-render. const hasChange = (Object.keys(patch) as (keyof typeof patch)[]).some( (key) => patch[key] !== entry[key] ) if (!hasChange) { - return {} + return s } return { pendingWorktreeCreations: { @@ -51,7 +51,7 @@ export function createRemovePendingWorktreeCreation( set((s) => { const entry = s.pendingWorktreeCreations[creationId] if (!entry) { - return {} + return s } removedEntry = entry const { [creationId]: _removed, ...rest } = s.pendingWorktreeCreations @@ -90,7 +90,7 @@ export function createSetActivePendingWorktreeCreation( return (creationId) => { set((s) => { if (creationId !== null && !s.pendingWorktreeCreations[creationId]) { - return {} + return s } return { activePendingCreationId: creationId } }) diff --git a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts index fdf17d38f8f..35464523ba5 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts @@ -261,7 +261,7 @@ export function applyHostedReviewLinkClear( nextWorktrees === s.worktreesByRepo && nextDetectedWorktrees === s.detectedWorktreesByRepo ) { - return {} + return s } return { ...(nextWorktrees !== s.worktreesByRepo diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index 5e5c38a71e2..414a2ef206a 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -149,7 +149,7 @@ export function createUpdateWorktreeMeta( shouldApplyUpdate && !shouldApplyUpdate(findKnownWorktreeById(s, worktreeId, executionHostId)) ) { - return {} + return s } didApply = true const nextWorktrees = applyWorktreeUpdates( @@ -204,7 +204,7 @@ export function createUpdateWorktreeMeta( !cacheKey && !prCacheKey ) { - return {} + return s } const nextHostedReviewCache = diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts index 78254535106..8ae7be3ea8b 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts @@ -73,7 +73,7 @@ export function createUpdateWorktreesMeta( } return nextWorktrees === s.worktreesByRepo && nextDetectedWorktrees === s.detectedWorktreesByRepo - ? {} + ? s : { ...(nextWorktrees !== s.worktreesByRepo ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } diff --git a/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts b/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts index 75d6059f309..d14392e72d9 100644 --- a/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts +++ b/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts @@ -14,7 +14,10 @@ export function createMigrateWorktreeIdentity( } // Why: invalidate pre-rename toast actions before publishing the new path, carrying the dismissal forward. migrateHugeRepoWarningDismissal(oldWorktreeId, newWorktreeId) - set((s) => buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId)) + set((s) => { + const patch = buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId) + return Object.keys(patch).length > 0 ? patch : s + }) migrateHostedReviewLinkMutationGeneration(oldWorktreeId, newWorktreeId) } } diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index 31c7e8bbb37..84c85c45a3c 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -217,15 +217,15 @@ export function createSetActiveWorktree( pendingActivationTerminalPrepCancels.delete(worktreeId) set((s) => { if (s.activeWorktreeId !== worktreeId) { - return {} + return s } const tabs = s.tabsByWorktree[worktreeId] ?? [] if (tabs.length === 0) { - return {} + return s } const allDead = tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) if (!allDead && !shouldTagTerminalTabs) { - return {} + return s } return { tabsByWorktree: { diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts b/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts new file mode 100644 index 00000000000..7fdaea7999f --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from '../../worktrees-slice-test-harness' +import { makeWorktree } from '../../worktrees-slice-test-fixtures' + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() } +})) +vi.mock('@/components/worktree-base-fallback-notice', () => ({ + requestWorktreeBaseFallbackNotice: vi.fn() +})) + +describe('worktree no-op notifications', () => { + it('keeps missing creation, recovery, activity, deletion and visit updates silent', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.updatePendingWorktreeCreation('missing', { phase: 'fetching' }) + before.removePendingWorktreeCreation('missing') + before.setActivePendingWorktreeCreation('missing') + before.remountTerminalTabForRecovery('missing') + before.settleTerminalTabRecovery('missing', 1, 'success') + before.markWorktreeUnread('missing') + before.bumpWorktreeActivity('missing') + before.clearWorktreeDeleteState('missing') + before.seedActiveWorktreeLastVisitedIfMissing() + before.pruneLastVisitedTimestamps() + before.migrateWorktreeIdentity('missing-old', 'missing-new') + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it.each(['local', 'ssh:test'] as const)( + 'keeps repeated %s deletion and visit updates silent', + (hostId) => { + const store = createTestStore() + const worktree = makeWorktree({ id: 'repo1::/path/wt', repoId: 'repo1', hostId }) + store.setState({ worktreesByRepo: { repo1: [worktree] } }) + const target = { id: worktree.id, hostId } + store.getState().markWorktreesQueuedForDeletion([target]) + store.getState().markWorktreeVisited(worktree.id, 100, hostId) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.markWorktreesQueuedForDeletion([target]) + before.markWorktreeVisited(worktree.id, 100, hostId) + before.markWorktreeVisited(worktree.id, 99, hostId) + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + + before.markWorktreesDeleting([target]) + expect(listener).toHaveBeenCalledTimes(1) + const deleting = store.getState() + deleting.markWorktreesDeleting([target]) + expect(store.getState()).toBe(deleting) + expect(listener).toHaveBeenCalledTimes(1) + deleting.clearWorktreeDeleteState(worktree.id, hostId) + expect(listener).toHaveBeenCalledTimes(2) + } + ) +}) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 78e0e397bca..66e6ddf5b51 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -55,7 +55,7 @@ export function createRemountTerminalTabForRecovery( const { admitted: _admitted, ...decline } = admission result = { remounted: false, ...decline } } - return {} + return s } const { worktreeId, index, tab } = location const nextTabs = s.tabsByWorktree[worktreeId].slice() @@ -110,12 +110,12 @@ export function createSettleTerminalTabRecovery( set((s) => { const location = locateTerminalTab(s.tabsByWorktree, tabId) if (!location) { - return {} + return s } const { worktreeId, index, tab } = location const recovery = settledTerminalRecoveryLedger(tab, generation, outcome) if (!recovery) { - return {} + return s } const nextTabs = s.tabsByWorktree[worktreeId].slice() nextTabs[index] = { ...tab, recovery } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts index 72d95b6a0a4..bea375b8292 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts @@ -59,7 +59,7 @@ export function createMarkWorktreeUnread( set((s) => { const worktree = findKnownWorktreeById(s, worktreeId) if (!worktree || worktree.isUnread) { - return {} + return s } shouldPersist = true const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { @@ -266,7 +266,7 @@ export function createBumpWorktreeActivity( set((s) => { const worktree = findKnownWorktreeById(s, worktreeId) if (!worktree) { - return {} + return s } shouldPersist = true // Why: skip sortEpoch bump for the active worktree — its PTY events are click side-effects (reorder-on-click bug, PR #209). diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts index 5cfc5eef3d2..7a9d94c89fd 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts @@ -29,7 +29,7 @@ export function createMarkWorktreeVisited( hostId: ownerHostId }) ?? 0 if (!(now > prev)) { - return {} + return s } return { lastVisitedAtByWorktreeId: { @@ -124,7 +124,7 @@ export function createPruneLastVisitedTimestamps( patch.activeWorkspaceExecutionHostId = null } } - return Object.keys(patch).length > 0 ? patch : {} + return Object.keys(patch).length > 0 ? patch : s }) } } @@ -137,12 +137,12 @@ export function createSeedActiveWorktreeLastVisitedIfMissing( set((s) => { const id = s.activeWorktreeId if (!id) { - return {} + return s } const hostId = s.activeWorkspaceExecutionHostId ?? s.getKnownWorktreeById(id)?.hostId const key = getWorktreeVisitKey(id, hostId) if (getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, { id, hostId }) != null) { - return {} + return s } return { lastVisitedAtByWorktreeId: { diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts index 30975b958e6..7e89bb2e34b 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts @@ -78,7 +78,7 @@ export function createMarkWorktreesDeleting( } changed = true } - return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + return changed ? { deleteStateByWorktreeId: nextDeleteState } : s }) } } @@ -113,7 +113,7 @@ export function createMarkWorktreesQueuedForDeletion( } changed = true } - return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + return changed ? { deleteStateByWorktreeId: nextDeleteState } : s }) } } @@ -128,7 +128,7 @@ export function createClearWorktreeDeleteState( : worktreeId set((s) => { if (!s.deleteStateByWorktreeId[key]) { - return {} + return s } const next = { ...s.deleteStateByWorktreeId } delete next[key] diff --git a/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts b/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts index d67a382a421..fc3abb5edd6 100644 --- a/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts +++ b/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts @@ -16,7 +16,7 @@ export function createTerminalDisownedPtySourceActions( markPtySourceDisowned: (ptyId) => { set((state) => state.disownedPtyIds[ptyId] - ? {} + ? state : { disownedPtyIds: { ...state.disownedPtyIds, [ptyId]: true } } ) } diff --git a/src/renderer/src/store/terminals/terminal-ephemeral-state.ts b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts index 89a67bd72ae..80de6b6899a 100644 --- a/src/renderer/src/store/terminals/terminal-ephemeral-state.ts +++ b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts @@ -30,7 +30,7 @@ export function createTerminalEphemeralActions( markDefaultTerminalTabsApplied: (worktreeId) => set((s) => { if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { - return {} + return s } return { defaultTerminalTabsAppliedByWorktreeId: { @@ -70,7 +70,7 @@ export function createTerminalEphemeralActions( set((s) => { const current = s.nativeChatLaunchPromptByTabId[tabId] if (!current || current.failed) { - return {} + return s } return { nativeChatLaunchPromptByTabId: { @@ -83,7 +83,7 @@ export function createTerminalEphemeralActions( clearNativeChatLaunchPrompt: (tabId) => { set((s) => { if (!s.nativeChatLaunchPromptByTabId[tabId]) { - return {} + return s } const next = { ...s.nativeChatLaunchPromptByTabId } delete next[tabId] @@ -102,7 +102,7 @@ export function createTerminalEphemeralActions( set((s) => { const current = s.nativeChatLaunchDraftByTabId[tabId] if (!current || current.adopted) { - return {} + return s } return { nativeChatLaunchDraftByTabId: { @@ -121,7 +121,7 @@ export function createTerminalEphemeralActions( current.createdAt !== resolution.createdAt || current.text !== resolution.text ) { - return {} + return s } return { nativeChatLaunchDraftByTabId: { @@ -134,7 +134,7 @@ export function createTerminalEphemeralActions( clearNativeChatLaunchDraft: (tabId) => { set((s) => { if (!s.nativeChatLaunchDraftByTabId[tabId]) { - return {} + return s } const next = { ...s.nativeChatLaunchDraftByTabId } delete next[tabId] @@ -168,7 +168,7 @@ export function createTerminalEphemeralActions( next ??= { ...s.lastTerminalInputAtByPaneKey } next[key] = at } - return next ? { lastTerminalInputAtByPaneKey: next } : {} + return next ? { lastTerminalInputAtByPaneKey: next } : s }) } }) @@ -227,7 +227,7 @@ export function createTerminalEphemeralActions( removeDeferredSshSessionId: (tabId) => set((s) => { if (!s.deferredSshSessionIdsByTabId[tabId]) { - return {} + return s } const next = { ...s.deferredSshSessionIdsByTabId } delete next[tabId] diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index 9b5cf8323c2..42ad3dbc659 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -30,7 +30,7 @@ export function createTerminalLayoutActions( set((s) => { const layout = s.terminalLayoutsByTabId[tabId] if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) { - return {} + return s } return { terminalLayoutsByTabId: { diff --git a/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts new file mode 100644 index 00000000000..4d68e867770 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + flushTerminalInputActivity, + resetTerminalInputActivityCoalescingForTests +} from '@/lib/terminal-input-activity-coalescing' +import { createTestStore, makeLayout } from '../slices/store-test-helpers' + +afterEach(resetTerminalInputActivityCoalescingForTests) + +describe('terminal no-op subscriber budget', () => { + it('does not publish missing-entry cleanup and restart actions', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + for (let i = 0; i < 25; i += 1) { + const s = store.getState() + s.replaceTerminalLayoutPanePtyId('missing', 'leaf', 'pty') + expect(s.consumeSuppressedPtyExit('missing')).toBe(false) + expect(s.consumePendingCodexPaneRestart('missing')).toBe(false) + s.clearCodexRestartNotice('missing') + s.dismissCodexRestartNotices(['missing']) + s.reopenCodexRestartPrompt('missing') + s.markNativeChatLaunchPromptFailed('missing') + s.clearNativeChatLaunchPrompt('missing') + s.markNativeChatLaunchDraftAdopted('missing') + s.resolveNativeChatLaunchDraft('missing', { text: 'draft', createdAt: 1 }) + s.clearNativeChatLaunchDraft('missing') + s.removeDeferredSshSessionId('missing') + } + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + }) + + it('publishes real mutations once and keeps repeated actions silent', () => { + const store = createTestStore() + const draft = { tabId: 'tab', agent: 'codex', text: 'draft', createdAt: 1 } as const + store.getState().seedNativeChatLaunchPrompt(draft) + store.getState().seedNativeChatLaunchDraft(draft) + store.getState().setTabLayout('tab', makeLayout()) + const listener = vi.fn() + store.subscribe(listener) + + const actions = [ + () => store.getState().markDefaultTerminalTabsApplied('folder-workspace'), + () => store.getState().markUnverifiedPtyLoss('tab'), + () => store.getState().markPtySourceDisowned('pty'), + () => store.getState().markNativeChatLaunchPromptFailed('tab'), + () => store.getState().markNativeChatLaunchDraftAdopted('tab'), + () => store.getState().resolveNativeChatLaunchDraft('tab', draft), + () => store.getState().replaceTerminalLayoutPanePtyId('tab', 'leaf', 'pty'), + () => store.getState().clearNativeChatLaunchPrompt('tab'), + () => store.getState().clearNativeChatLaunchDraft('tab') + ] + for (const action of actions) { + listener.mockClear() + action() + expect(listener).toHaveBeenCalledTimes(1) + const before = store.getState() + action() + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState()).toBe(before) + } + }) + + it('retains draft generations when stale resolutions arrive without notifying', () => { + const store = createTestStore() + const draft = { tabId: 'tab', agent: 'codex', text: 'new draft', createdAt: 2 } as const + store.getState().seedNativeChatLaunchDraft(draft) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + store.getState().resolveNativeChatLaunchDraft('tab', { ...draft, createdAt: 1 }) + store.getState().resolveNativeChatLaunchDraft('tab', { ...draft, text: 'old draft' }) + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + expect(store.getState().nativeChatLaunchDraftByTabId.tab).toBe(draft) + }) + + it('consumes real restart entries and leaves repeated consumes silent', () => { + const store = createTestStore() + store.getState().suppressPtyExit('pty') + store.getState().queueCodexPaneRestarts(['pty']) + const listener = vi.fn() + store.subscribe(listener) + + expect(store.getState().consumeSuppressedPtyExit('pty')).toBe(true) + expect(store.getState().consumePendingCodexPaneRestart('pty')).toBe(true) + expect(listener).toHaveBeenCalledTimes(2) + const before = store.getState() + expect(store.getState().consumeSuppressedPtyExit('pty')).toBe(false) + expect(store.getState().consumePendingCodexPaneRestart('pty')).toBe(false) + expect(listener).toHaveBeenCalledTimes(2) + expect(store.getState()).toBe(before) + }) + + it('drops a trailing input flush after pane teardown without publishing', () => { + const store = createTestStore() + store.getState().recordTerminalInput('tab:leaf', 1000) + store.getState().recordTerminalInput('tab:leaf', 1001) + store.setState({ lastTerminalInputAtByPaneKey: {} }) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + flushTerminalInputActivity() + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + expect(store.getState().lastTerminalInputAtByPaneKey['tab:leaf']).toBeUndefined() + }) + + it('dismisses, reopens and clears restart notices without replaying no-op notifications', () => { + const store = createTestStore() + store + .getState() + .markCodexRestartNotices([ + { ptyId: 'pty', previousAccountLabel: 'old', nextAccountLabel: 'new' } + ]) + const listener = vi.fn() + store.subscribe(listener) + const actions = [ + () => store.getState().dismissCodexRestartNotices(['pty']), + () => store.getState().reopenCodexRestartPrompt('pty'), + () => store.getState().clearCodexRestartNotice('pty') + ] + for (const [index, action] of actions.entries()) { + if (index === 1) { + store.getState().queueCodexPaneRestarts(['pty']) + } + listener.mockClear() + action() + expect(listener).toHaveBeenCalledTimes(1) + const before = store.getState() + action() + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState()).toBe(before) + } + expect(store.getState().codexRestartNoticeByPtyId.pty).toBeUndefined() + expect(store.getState().pendingCodexPaneRestartIds.pty).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-restart-state.ts b/src/renderer/src/store/terminals/terminal-restart-state.ts index f1075bdb698..2b712b6ccdb 100644 --- a/src/renderer/src/store/terminals/terminal-restart-state.ts +++ b/src/renderer/src/store/terminals/terminal-restart-state.ts @@ -21,7 +21,7 @@ export function createTerminalRestartActions( let wasSuppressed = false set((s) => { if (!s.suppressedPtyExitIds[ptyId]) { - return {} + return s } wasSuppressed = true const next = { ...s.suppressedPtyExitIds } @@ -68,7 +68,7 @@ export function createTerminalRestartActions( let wasQueued = false set((s) => { if (!s.pendingCodexPaneRestartIds[ptyId]) { - return {} + return s } wasQueued = true const next = { ...s.pendingCodexPaneRestartIds } @@ -144,7 +144,7 @@ export function createTerminalRestartActions( clearCodexRestartNotice: (ptyId) => { set((s) => { if (!s.codexRestartNoticeByPtyId[ptyId]) { - return {} + return s } const next = { ...s.codexRestartNoticeByPtyId } const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } @@ -175,7 +175,7 @@ export function createTerminalRestartActions( changed = true } if (!changed) { - return {} + return s } return { codexRestartNoticeByPtyId: next, @@ -187,7 +187,7 @@ export function createTerminalRestartActions( set((s) => { const notice = s.codexRestartNoticeByPtyId[ptyId] if (!notice?.restartRequested) { - return {} + return s } const { restartRequested: _restartRequested, ...kept } = notice const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } diff --git a/src/renderer/src/store/terminals/terminal-startup-queues.ts b/src/renderer/src/store/terminals/terminal-startup-queues.ts index 0f684e3f8b7..af56050ef23 100644 --- a/src/renderer/src/store/terminals/terminal-startup-queues.ts +++ b/src/renderer/src/store/terminals/terminal-startup-queues.ts @@ -62,7 +62,7 @@ export function createTerminalStartupQueueActions( } set((s) => { if (s.pendingStartupByTabId[tabId] !== pending) { - return {} + return s } const next = { ...s.pendingStartupByTabId } delete next[tabId] diff --git a/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts b/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts index 9f381a02c4e..075d349a528 100644 --- a/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts +++ b/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts @@ -8,7 +8,7 @@ export function createTerminalUnverifiedPtyLossActions( markUnverifiedPtyLoss: (tabId) => { set((state) => state.unverifiedPtyLossTabIds[tabId] - ? {} + ? state : { unverifiedPtyLossTabIds: { ...state.unverifiedPtyLossTabIds, [tabId]: true } } ) } From 955051ded04c3349c3f4dd995fad0a4a39300ad9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:37:13 -0700 Subject: [PATCH 15/43] fix(codex): settle a structured send on admission, and stop minting a colliding identity (#20138) * fix(codex): settle a structured send on admission, and stop minting a colliding identity Two sends could be written into the journal under one durable identity. Codex coalesces a mid-turn `turn/start` into the running turn rather than refusing it -- measured against real `codex app-server` builds 0.147.0, 0.150.1 and 0.153.4, none of which refuse and none of which fire a second `turn/started`. The dispatch path read the turn id from the turn/start response and stamped every accepted send `ordinal: 0`. Since a coalesced send gets the running turn's id back, two submissions persisted the same `providerItemId`. That string is durable, and it is the key a restore uses to match a submission against provider history, so the second message's real history row matched nothing and rendered as an extra bubble on replay. On 0.147.0 it is worse than a collision: the coalesced response returns a turn id that never starts and never completes, so the persisted key named a turn absent from history and NEITHER message could match. Identity is now minted from the echoed user message at `identityFor` -- the single point that mints the journal row's own identity -- so the settled key is by construction the one replay computes, rather than a parallel calculation that can drift. Dispatch returns `admitted` when the transport write completes; identity settles on the echo through a channel that did not previously exist for Codex. Waiters are keyed by client message id instead of being shifted off the front of an array by arrival order, and they are cleared on session close and child exit -- previously a timeout was the only thing that ever ended one. `TURN_ID_WAIT_MS` is deleted. It was never reachable on any build measured: `readCodexTurnId` returns non-null on all three, so the 10s wait never fired. The comment justifying it claimed older builds acknowledge before the id exists, which no tested build does. Three comments asserting Codex answers a mid-turn send with `turn already running` are corrected. Their only backing was a test fixture inventing that error string. The correction is factual only -- every changed line in `src/main/runtime/orchestration/` is a comment, and mid-turn delivery is still refused for both providers. Whether that policy is right is a separate question; it was resting on a false premise. Known gap, stated rather than implied: this prevents new collisions and does not repair journals already written with a colliding or phantom key. Those conversations keep duplicating on restore. Repairing them means re-matching persisted submissions against provider history and rewriting `providerItemId` -- which is what `journal-submission-reconciler.ts` is written for, and it still has no production caller. * test(codex): drop the synchronous-accept contract and the colliding `:0` from the integration fakes Three tests in the structured-session integration suites encoded the dispatch contract this branch replaces, and two of them pinned the defect it fixes. They asserted `agentSession.send` answers `dispatchState: 'accepted'` carrying `providerItemId: codex:::0` at send time. That ordinal was never observed; it was stamped on every accepted send, which is exactly the collision this branch removes -- a send coalesced into a running turn is answered with the running turn's id, so two submissions persisted one durable key. The visible failure was a 30s timeout rather than a failed assertion. The fake client advertised no `agent-session.pending-send-result.v1`, and without it the host holds the reply until the send settles: a shim for clients too old to render a pending bubble. The fake provider then echoed the user message with no `clientId`, so nothing could correlate that echo back to the submission, and the wait ran to its own 30s ceiling. Real Codex sends `clientId` on that echo, and the fake now does too, which is what makes it a model of the provider rather than a sketch of one. The identity assertion is kept rather than dropped. Each send now asserts `pending` with no identity at admission, then asserts the submission settles `accepted` at `codex:::0` once the echo lands. Same ordinal, but earned from `identityFor` on the echo -- the key a replay recomputes -- instead of guessed from the turn/start response. Ablated: removing `clientId` from the two echoes leaves both submissions `pending` and fails both assertions, so the assertion is load-bearing and not satisfied by something incidental. Both suites' client fixtures now advertise the capability set the desktop renderer sends in `src/main/ipc/runtime.ts`, which is what these suites mean by a client. The older-client settlement wait keeps its own coverage in `src/main/runtime/rpc/methods/structured-agent-session.test.ts`. `structured-agent-session-runtime-exit.test.ts` asserts `pending` for the same reason; it drives the host directly, so it never took the compatibility path, and what proves delivery there is still the turn the reacquired provider starts. The replay suite's "without dispatching it twice" property is untouched: one `turn/start` call, one replayed ledger row. * fix(codex): preserve unsettled dispatch correlations * test(codex): type the dispatch fixtures instead of asserting over them main's new casting gate (#20367 base) flags type assertions on changed lines. Replace them with checked types: the recording sink already satisfies its interface, both CodexSession fixtures are now annotated and carry real collaborators, the settlement assertion compares whole identities, and the integration helper reads submissions through the host's public journalSnapshot instead of its private session map. * fix(test): merge the duplicate doubt-reasons import the merge left behind Both sides added an import from journal-dispatch-doubt-reasons and the merge kept both statements, which the whole-repo native plugin gate refuses under --deny-warnings. * test(codex): a Fast mode turn is admitted, not accepted #20506 landed its Fast mode tests against the dispatch contract this branch replaces: a Codex send now returns admitted and settles its identity on the provider echo. The tier assertions the test exists for are untouched. --------- Co-authored-by: Merge Sim --- .../ai-vault-search/session-search-store.ts | 44 ++-- .../codex-requested-close-turn-timing.test.ts | 23 +- ...odex-structured-dispatch-admission.test.ts | 215 ++++++++++++++++++ .../codex-structured-dispatch-echo.test.ts | 108 +++++++++ .../codex/codex-structured-dispatch-echo.ts | 58 +++++ .../codex-structured-dispatch-test-support.ts | 139 +++++++++++ .../codex/codex-structured-fast-mode.test.ts | 4 +- .../codex-structured-journal-contracts.ts | 4 + .../codex/codex-structured-journal-items.ts | 7 +- ...ex-structured-journal-translation-turns.ts | 2 +- .../codex/codex-structured-provider-events.ts | 16 +- .../codex/codex-structured-session-acquire.ts | 13 +- .../codex-structured-session-adapter.test.ts | 42 +--- .../codex-structured-session-cancel.test.ts | 5 +- .../codex-structured-session-close.test.ts | 14 +- .../codex/codex-structured-session-close.ts | 3 + .../codex-structured-session-options.test.ts | 3 +- .../codex/codex-structured-session-state.ts | 15 +- src/main/codex/codex-structured-turn-start.ts | 100 ++++---- src/main/codex/codex-turn-ordinals.ts | 4 + .../journal-crash-boundary.test.ts | 9 +- .../journal-dispatch-doubt-reasons.ts | 7 - ...red-agent-session-send-idempotency.test.ts | 6 +- .../structured-agent-session-send.test.ts | 17 +- .../structured-mailbox-pointer-host.test.ts | 4 +- .../structured-session-pointer-delivery.ts | 13 +- .../structured-worker-group-addressing.ts | 4 +- ...d-agent-session-integration-replay.test.ts | 13 +- ...ructured-agent-session-integration.test.ts | 83 +++++-- ...uctured-agent-session-runtime-exit.test.ts | 6 +- .../structured-agent-session-runtime.ts | 23 +- ...ctured-agent-session-dispatch-rejection.ts | 9 +- 32 files changed, 805 insertions(+), 208 deletions(-) create mode 100644 src/main/codex/codex-structured-dispatch-admission.test.ts create mode 100644 src/main/codex/codex-structured-dispatch-echo.test.ts create mode 100644 src/main/codex/codex-structured-dispatch-echo.ts create mode 100644 src/main/codex/codex-structured-dispatch-test-support.ts diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts index 71215bb7174..435fa16ce8a 100644 --- a/src/main/ai-vault-search/session-search-store.ts +++ b/src/main/ai-vault-search/session-search-store.ts @@ -199,30 +199,32 @@ export class SessionSearchStore { files(): SessionSearchFileRow[] { return ( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The files schema and SELECT aliases define this row; REAL casts return numeric IDs or null. - this.db - .prepare( - // Numeric stat IDs may exceed SQLite's safe INTEGER-to-number read range. - `SELECT path, CAST(dev AS REAL) AS dev, CAST(ino AS REAL) AS ino, + ( + this.db + .prepare( + // Numeric stat IDs may exceed SQLite's safe INTEGER-to-number read range. + `SELECT path, CAST(dev AS REAL) AS dev, CAST(ino AS REAL) AS ino, mtime_ms AS mtimeMs, size_bytes AS sizeBytes, state, fail_count AS failCount, failed_mtime_ms AS failedMtimeMs FROM files` - ) - .all() as (Omit & { - dev: number | null - ino: number | null - })[] - ).map((row) => ({ - path: row.path, - identity: - typeof row.dev === 'number' && typeof row.ino === 'number' - ? { dev: row.dev, ino: row.ino } - : null, - mtimeMs: row.mtimeMs, - sizeBytes: row.sizeBytes, - state: row.state, - failCount: row.failCount, - failedMtimeMs: row.failedMtimeMs - })) + ) + .all() as (Omit & { + dev: number | null + ino: number | null + })[] + ).map((row) => ({ + path: row.path, + identity: + typeof row.dev === 'number' && typeof row.ino === 'number' + ? { dev: row.dev, ino: row.ino } + : null, + mtimeMs: row.mtimeMs, + sizeBytes: row.sizeBytes, + state: row.state, + failCount: row.failCount, + failedMtimeMs: row.failedMtimeMs + })) + ) } /** diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index 29039ffca3c..fbf50badaef 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -1,8 +1,10 @@ +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' import { closeCodexPublishedSession } from './codex-structured-session-close' import type { CodexSession } from './codex-structured-session-state' @@ -48,17 +50,30 @@ describe('requested-close durable turn timing', () => { observedAt: 1_000 }) ).toEqual({ accepted: true }) - const session = { - connection: { close: vi.fn(async () => true) }, + const session: CodexSession = { + connection: { + pid: 4321, + closed: false, + request: async () => ({}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, requestedClose: false, fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - prompts: { clear: vi.fn() }, + historyPath: null, + prompts: new CodexPromptRegistry(), + options: new Map(), + reportedOptions: {}, + fastModeTierByModel: new Map(), + dispatchEchoes: createCodexDispatchEchoes(), translator - } as unknown as CodexSession + } const sessions = new Map([['session-1', session]]) const onEvent = vi.fn() diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts new file mode 100644 index 00000000000..3eece95c3fe --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { MAX_CODEX_PENDING_DISPATCH_ECHOES } from './codex-structured-dispatch-echo' +import { + acquiredCodexAdapter, + echoUserMessage, + fakeCodexAppServer, + startTurn, + CODEX_TEST_THREAD_ID, + CODEX_TEST_USER_MESSAGE, + type LateSettlement +} from './codex-structured-dispatch-test-support' + +function send( + adapter: Awaited>, + clientMessageId: string +): Promise { + return adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: CODEX_TEST_USER_MESSAGE, + fence: 7 + }) +} + +describe('codex dispatch admission', () => { + it('admits a send queued behind a running turn and settles it when Codex echoes it', async () => { + // Measured on codex-cli 0.153.4: a `turn/start` issued while a turn runs is + // COALESCED into it -- same turn id back, no second `turn/started`, and the + // user message echoed only once the running turn reaches it. + const codex = fakeCodexAppServer({ + 'turn/start': () => ({ turn: { id: 'turn-1', status: 'inProgress' } }) + }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + + const outcome = await send(adapter, 'client-2') + + // No doubt: elapsed time is not evidence, so nothing invites a Retry. + expect(outcome).toEqual({ state: 'admitted' }) + expect(settlements).toEqual([]) + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u2', clientId: 'client-2' }) + + // Ordinal 1, not 0: the queued send is the SECOND user message of the turn + // it was coalesced into, which is the key a history replay computes for it. + expect(settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-2', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 1 + } + } + ]) + }) + + it('correlates each send by client message id, not queue order', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + await send(adapter, 'client-1') + await send(adapter, 'client-2') + + // The echoes arrive in the opposite order to the sends. + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u2', clientId: 'client-2' }) + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + + // Ordinals follow the ECHO order, and each one lands on the send whose + // `clientId` it carried -- not on the send that was queued in that slot. + expect(settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-2', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 0 + } + }, + { + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 1 + } + } + ]) + }) + + it('settles nothing for a user message this session never sent', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + await send(adapter, 'client-1') + + // A message another client sent on the same thread, and one Codex did not + // correlate at all. + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-x', clientId: 'someone-else' }) + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-y' }) + + expect(settlements).toEqual([]) + }) + + it('rejects only when Codex answered and declined, and arms nothing for it', async () => { + const { CodexAppServerRequestError } = await import('./codex-app-server-connection') + const codex = fakeCodexAppServer({ + 'turn/start': () => { + throw new CodexAppServerRequestError('turn/start', -32602, 'thread not found') + } + }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + expect(await send(adapter, 'client-1')).toEqual({ + state: 'rejected', + reason: 'thread not found' + }) + + // A refused write is disarmed, so a later echo of that id settles nothing. + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) + + it('retains correlation when a request fails after its write may have landed', async () => { + const codex = fakeCodexAppServer({ + 'turn/start': () => { + throw new Error('request timed out after write') + } + }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + await expect(send(adapter, 'client-1')).rejects.toThrow('request timed out after write') + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + + expect(settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 0 + } + } + ]) + }) + + it('refuses overflow without discarding an older accepted send', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + for (let index = 0; index < MAX_CODEX_PENDING_DISPATCH_ECHOES; index += 1) { + expect(await send(adapter, `client-${index}`)).toEqual({ state: 'admitted' }) + } + expect(await send(adapter, 'client-overflow')).toEqual({ + state: 'rejected', + reason: 'codex structured dispatch queue is full' + }) + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u0', clientId: 'client-0' }) + expect(settlements.map(({ clientMessageId }) => clientMessageId)).toEqual(['client-0']) + }) + + it('leaves no waiter behind when the session closes', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + await send(adapter, 'client-1') + + await adapter.closeSession('session-1') + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) + + it('leaves no waiter behind when the child exits', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + await send(adapter, 'client-1') + + connection.handlers.onExit?.(new Error('codex app-server exited')) + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts new file mode 100644 index 00000000000..58203b26964 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { + createCodexDispatchEchoes, + readCodexDispatchEcho, + MAX_CODEX_PENDING_DISPATCH_ECHOES +} from './codex-structured-dispatch-echo' + +const CODEX_IDENTITY: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 3 +} + +describe('codex dispatch echoes', () => { + it('settles by client message id rather than arrival order', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.arm('client-2') + + // Codex coalesces both sends into one turn, and the second can be echoed + // first. Queue position would settle the wrong submission here. + expect(echoes.settle('client-2')).toBe(true) + expect(echoes.settle('client-1')).toBe(true) + expect(echoes.size).toBe(0) + }) + + it('refuses an echo this session never armed', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + + expect(echoes.settle('client-from-history')).toBe(false) + expect(echoes.size).toBe(1) + }) + + it('settles a send exactly once', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + + expect(echoes.settle('client-1')).toBe(true) + expect(echoes.settle('client-1')).toBe(false) + }) + + it('drops a send whose write never reached the provider', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.disarm('client-1') + + expect(echoes.settle('client-1')).toBe(false) + }) + + it('clears every armed send', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.arm('client-2') + + echoes.clear() + + expect(echoes.size).toBe(0) + expect(echoes.settle('client-1')).toBe(false) + }) + + it('refuses new correlations at capacity without dropping an older send', () => { + const echoes = createCodexDispatchEchoes() + for (let index = 0; index < MAX_CODEX_PENDING_DISPATCH_ECHOES; index += 1) { + expect(echoes.arm(`client-${index}`)).toBe(true) + } + + expect(echoes.arm(`client-${MAX_CODEX_PENDING_DISPATCH_ECHOES}`)).toBe(false) + expect(echoes.size).toBe(MAX_CODEX_PENDING_DISPATCH_ECHOES) + expect(echoes.settle('client-0')).toBe(true) + expect(echoes.settle(`client-${MAX_CODEX_PENDING_DISPATCH_ECHOES}`)).toBe(false) + }) +}) + +describe('readCodexDispatchEcho', () => { + it('reads the client message id off a user message', () => { + expect( + readCodexDispatchEcho( + { type: 'userMessage', id: 'item-1', clientId: 'client-1' }, + CODEX_IDENTITY + ) + ).toEqual({ clientMessageId: 'client-1', providerIdentity: CODEX_IDENTITY }) + }) + + it('ignores an item that is not a user message', () => { + expect( + readCodexDispatchEcho( + { type: 'agentMessage', id: 'item-1', clientId: 'client-1' }, + CODEX_IDENTITY + ) + ).toBeNull() + }) + + it('ignores a user message Codex did not correlate', () => { + expect(readCodexDispatchEcho({ type: 'userMessage', id: 'item-1' }, CODEX_IDENTITY)).toBeNull() + }) + + it('ignores an item with no durable Codex identity', () => { + expect( + readCodexDispatchEcho( + { type: 'userMessage', id: 'item-1', clientId: 'client-1' }, + { provider: 'orca', clientMessageId: 'codex-item:thread-1:item-1' } + ) + ).toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts new file mode 100644 index 00000000000..8ea97561c59 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -0,0 +1,58 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' + +/** Sends awaiting their echo, oldest first. A send whose echo never arrives is + * retired by the journal's pending-submission recovery on exit, not from here. */ +export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 + +/** + * Which sends this session is still waiting to hear back about, keyed by the + * client message id Codex echoes on the user message. + * + * Keyed rather than ordered on purpose: Codex coalesces a `turn/start` issued + * while a turn is running into that turn, so two sends can share one turn id and + * their echoes arrive far apart. Queue position identifies neither. + */ +export type CodexDispatchEchoes = { + /** Arms settlement for a send about to be written; false preserves older waits at capacity. */ + arm: (clientMessageId: string) => boolean + /** True once, for a send this session armed and has not yet settled. */ + settle: (clientMessageId: string) => boolean + /** Drops an armed send whose write never reached the provider. */ + disarm: (clientMessageId: string) => void + clear: () => void + readonly size: number +} + +export function createCodexDispatchEchoes(): CodexDispatchEchoes { + const armed = new Set() + return { + arm(clientMessageId) { + if (!armed.has(clientMessageId) && armed.size >= MAX_CODEX_PENDING_DISPATCH_ECHOES) { + return false + } + armed.delete(clientMessageId) + armed.add(clientMessageId) + return true + }, + settle: (clientMessageId) => armed.delete(clientMessageId), + disarm: (clientMessageId) => void armed.delete(clientMessageId), + clear: () => armed.clear(), + get size() { + return armed.size + } + } +} + +/** The user-message echo a settlement is read off, or null for any other item. */ +export function readCodexDispatchEcho( + item: { type: string; id: string } & Record, + identity: AgentJournalItemIdentity +): { clientMessageId: string; providerIdentity: AgentJournalItemIdentity } | null { + if (item.type !== 'userMessage' || identity.provider !== 'codex') { + return null + } + const clientMessageId = item.clientId + return typeof clientMessageId === 'string' && clientMessageId.length > 0 + ? { clientMessageId, providerIdentity: identity } + : null +} diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts new file mode 100644 index 00000000000..5519ffdfdb8 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -0,0 +1,139 @@ +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + CodexAppServerLaunch, + openCodexAppServerConnection +} from './codex-app-server-connection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +export const CODEX_TEST_THREAD_ID = 'thread-abc' + +export const CODEX_TEST_USER_MESSAGE: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }] +} + +export type CodexTestRoute = (params: Record | undefined) => unknown + +type FakeConnection = Omit & { + closed: boolean + launch: CodexAppServerLaunch + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] +} + +export type LateSettlement = { + sessionId: string + clientMessageId: string + providerIdentity: AgentJournalItemIdentity +} + +/** A `codex app-server` whose turn traffic the test drives by hand. */ +export function fakeCodexAppServer(routes: Record = {}): { + connections: FakeConnection[] + openConnection: typeof openCodexAppServerConnection + routes: Record +} { + const connections: FakeConnection[] = [] + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + return routes[method]?.(params) ?? {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + routes['thread/start'] ??= () => ({ + thread: { id: CODEX_TEST_THREAD_ID, path: '/rollouts/abc.jsonl' } + }) + return { connections, openConnection, routes } +} + +/** A sink that records nothing but keeps the translator alive, which is what + * mints the identities a late settlement carries. */ +export function recordingSink(): StructuredAgentSessionEventSink { + return { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } +} + +export async function acquiredCodexAdapter(input: { + codex: ReturnType + settlements: LateSettlement[] + sink?: StructuredAgentSessionEventSink +}): Promise { + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: input.codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + captureTurnProcesses: async () => null, + now: () => 1_700_000_000_500, + onDispatchSettledLate: (settlement) => input.settlements.push(settlement) + }) + const identity: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: CODEX_TEST_THREAD_ID } + } + await adapter.acquire({ + identity, + fence: 7, + spawnToken: 'spawn-9', + events: input.sink ?? recordingSink() + }) + return adapter +} + +/** Codex's own echo of a user message Orca sent, inside `turnId`. */ +export function echoUserMessage( + connection: FakeConnection, + input: { turnId: string; itemId: string; clientId?: string; threadId?: string } +): void { + connection.handlers.onNotification?.('item/started', { + threadId: input.threadId ?? CODEX_TEST_THREAD_ID, + turn: { id: input.turnId }, + item: { + type: 'userMessage', + id: input.itemId, + ...(input.clientId ? { clientId: input.clientId } : {}) + } + }) +} + +export function startTurn(connection: FakeConnection, turnId: string): void { + connection.handlers.onNotification?.('turn/started', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: turnId } + }) +} diff --git a/src/main/codex/codex-structured-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 917133c7543..3029bff280b 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -112,7 +112,9 @@ describe('Codex structured Fast mode dispatch', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toMatchObject({ state: 'accepted' }) + // `admitted`, not `accepted`: a Codex send now settles its identity on + // the provider echo. What this test pins is the tier the turn carries. + ).resolves.toMatchObject({ state: 'admitted' }) expect( codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params ).toMatchObject({ serviceTier: 'default' }) diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index d7a902c9484..4114f9b0355 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -1,3 +1,4 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' @@ -15,6 +16,9 @@ export type CodexJournalTranslatorDeps = { turnId?: string | null ) => void clearPromptTurn?: (threadId: string, turnId: string) => void + /** Settles a send's identity off the echoed user message, using the very + * identity the journal row carries so a replay computes the same key. */ + onUserMessageEcho?: (clientMessageId: string, identity: AgentJournalItemIdentity) => void primaryThreadId?: () => string | null subagentExecutions?: CodexSubagentExecutions coalesceMs?: number diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 62091580da3..b5ab9ad90e1 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -29,6 +29,7 @@ import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structu import type { CodexActiveJournalItem } from './codex-structured-journal-settlement' import { readCodexJournalString } from './codex-structured-journal-translation-values' import { readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' export class CodexJournalItems { readonly ordinals = new CodexTurnOrdinals() @@ -40,7 +41,7 @@ export class CodexJournalItems { constructor( private readonly deps: Pick< CodexJournalTranslatorDeps, - 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' + 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' | 'onUserMessageEcho' > & { maxMetadataBytes?: number }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void @@ -78,6 +79,10 @@ export class CodexJournalItems { const identity = this.identityFor(event.threadId, turnId, item) // Count echoes for stable resume ordinals, but user bubbles come from submissions. if (source === 'live' && item.type === 'userMessage') { + const echo = readCodexDispatchEcho(item, identity) + if (echo) { + this.deps.onUserMessageEcho?.(echo.clientMessageId, echo.providerIdentity) + } return { handled: true, admission: CODEX_JOURNAL_ADMITTED } } if (item.type === 'contextCompaction' && event.method === 'item/started') { diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index d1cd7ca2884..e36322bca0c 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -6,7 +6,7 @@ import type { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { CODEX_USER_MESSAGE_ORDINAL } from './codex-structured-turn-start' +import { CODEX_USER_MESSAGE_ORDINAL } from './codex-turn-ordinals' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts index 989232ff1b2..69b4cb0d392 100644 --- a/src/main/codex/codex-structured-provider-events.ts +++ b/src/main/codex/codex-structured-provider-events.ts @@ -3,7 +3,7 @@ import { disposeCodexServerRequest } from './codex-server-request-disposition' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import * as codexRewind from './codex-structured-rewind' import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state' -import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexThreadId } from './codex-structured-thread-facts' import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' type EmitCodexEvent = ( @@ -41,10 +41,9 @@ export function deliverCodexNotification( return { accepted: true } } const threadId = readCodexThreadId(params) ?? session.threadId - const turnId = - method === 'turn/started' && threadId === session.threadId ? readCodexTurnId(params) : null - const turnWaiter = turnId ? session.turnIdWaiters[0] : undefined - const admission = emit(session, { + // Dispatch identity settles on the user-message echo inside the translator, + // which is where the ordinal a replay will compute is minted. + return emit(session, { type: 'notification', sessionId, threadId, @@ -52,13 +51,6 @@ export function deliverCodexNotification( params, ...(observedAt !== undefined ? { observedAt } : {}) }) - if (method === 'turn/started' && threadId === session.threadId) { - if (admission.accepted && turnId && session.turnIdWaiters[0] === turnWaiter) { - session.turnIdWaiters.shift() - turnWaiter?.(turnId) - } - } - return admission } export function deliverCodexServerRequest( diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index b104c559af2..c191e675973 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -10,6 +10,7 @@ import { } from './codex-structured-acquisition-lifecycle' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { CodexSubagentExecutions } from './codex-subagent-executions' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openCodexAppServerConnection } from './codex-app-server-connection' import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' @@ -81,6 +82,7 @@ export async function acquireCodexStructuredSession(input: { ? acquireInput.identity.providerHandle.threadId : null const subagentExecutions = new CodexSubagentExecutions() + const dispatchEchoes = createCodexDispatchEchoes() const translator = acquireInput.events ? createCodexJournalTranslator({ sink: acquireInput.events, @@ -90,7 +92,14 @@ export async function acquireCodexStructuredSession(input: { subagentExecutions, bindPromptItemId: (journalItemId, threadId, promptKey, turnId) => acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey, turnId), - clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId) + clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId), + onUserMessageEcho: (clientMessageId, providerIdentity) => { + // Only a send THIS session admitted; an echo from history restore or + // another client names no submission of ours to settle. + if (dispatchEchoes.settle(clientMessageId)) { + deps.onDispatchSettledLate?.({ sessionId, clientMessageId, providerIdentity }) + } + } }) : null const open = deps.openConnection ?? openCodexAppServerConnection @@ -230,7 +239,7 @@ export async function acquireCodexStructuredSession(input: { options, reportedOptions: reportedCodexThreadOptions(opened), fastModeTierByModel: fastModeCatalog?.fastModeTierByModel ?? new Map(), - turnIdWaiters: [], + dispatchEchoes, translator, backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions), forceCloseUnexpected: (reason) => diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index b32c7e69a1b..d32c3013b6c 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -315,7 +315,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) describe('CodexStructuredSessionAdapter.dispatch', () => { - it('accepts a turn Codex names in its response', async () => { + it('admits a send as soon as Codex owns it', async () => { const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) const adapter = await acquired(codex) @@ -334,10 +334,9 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toEqual({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-1', ordinal: 0 } - }) + // Identity is not knowable here: a send coalesced into a running turn shares + // that turn's id, so the echo settles which message landed where. + expect(outcome).toEqual({ state: 'admitted' }) expect(codex.connections[0].calls[1].params).toEqual({ threadId: THREAD_ID, clientUserMessageId: 'client-1', @@ -349,7 +348,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { }) }) - it('accepts a turn named only by the notification that raced the ack', async () => { + it('admits a send on a build whose turn/start answers before the turn is named', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const adapter = await acquired(codex, {}, events) @@ -368,8 +367,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toMatchObject({ state: 'accepted' }) - expect(outcome).toMatchObject({ providerIdentity: { turnId: 'turn-late' } }) + expect(outcome).toEqual({ state: 'admitted' }) expect(events.at(-1)).toMatchObject({ type: 'notification', method: 'turn/started' }) }) @@ -393,10 +391,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toEqual({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-root', ordinal: 0 } - }) + expect(outcome).toEqual({ state: 'admitted' }) // Each event carries the thread it actually came from, so the journal can // keep a subagent's turn out of the root conversation. expect(events.map((event) => (event.type === 'notification' ? event.threadId : null))).toEqual([ @@ -405,29 +400,6 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { ]) }) - it('settles unknown rather than failed when Codex never names the turn', async () => { - vi.useFakeTimers() - try { - const codex = fakeCodex() - const adapter = await acquired(codex) - - const dispatching = adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-1', - body: USER_MESSAGE, - fence: 7 - }) - await vi.advanceTimersByTimeAsync(10_000) - - expect(await dispatching).toEqual({ - state: 'unknown', - reason: 'codex app-server started a turn it did not name in time' - }) - } finally { - vi.useRealTimers() - } - }) - it('rejects only when Codex answered and declined', async () => { const codex = fakeCodex({ 'turn/start': () => { diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index 2ea81d44786..1ac807a5ccd 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -257,9 +257,8 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toMatchObject({ - state: 'accepted', - providerIdentity: { turnId: 'turn-2' } + ).resolves.toEqual({ + state: 'admitted' }) }) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 58c5bc5f50a..17f3aecf671 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -1,3 +1,4 @@ +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { @@ -11,6 +12,7 @@ import { } from './codex-structured-session-adapter' import { handleCodexSessionExit } from './codex-structured-session-close' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' import type { CodexSession } from './codex-structured-session-state' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' @@ -84,13 +86,13 @@ describe('Codex structured session close lifecycle', () => { respondWithError: () => {}, close: async () => true } - const prompts = { clear: vi.fn() } as unknown as CodexSession['prompts'] + const prompts = new CodexPromptRegistry() + const clearPrompts = vi.spyOn(prompts, 'clear') const translator = { handle: vi.fn().mockReturnValueOnce({ accepted: false, reason: 'backpressure' as const }), dispose: vi.fn() } as unknown as NonNullable - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the literal supplies every CodexSession field the close path reads; the rest are unused by it. - const session = { + const session: CodexSession = { connection, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, @@ -103,9 +105,9 @@ describe('Codex structured session close lifecycle', () => { options: new Map(), reportedOptions: {}, fastModeTierByModel: new Map(), - turnIdWaiters: [], + dispatchEchoes: createCodexDispatchEchoes(), translator - } as CodexSession + } const sessions = new Map([['session-1', session]]) const onEvent = vi.fn() @@ -120,7 +122,7 @@ describe('Codex structured session close lifecycle', () => { }) ).toBe(true) expect(session.ended).toBe(true) - expect(prompts.clear).toHaveBeenCalledOnce() + expect(clearPrompts).toHaveBeenCalledOnce() expect(onEvent).toHaveBeenCalledOnce() expect(translator.dispose).toHaveBeenCalledOnce() expect(onEvent.mock.calls[0]?.[0]).toMatchObject({ diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index af814c51d9b..2058f86ce85 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -47,6 +47,9 @@ export function handleCodexSessionExit(input: { event.settlementRetryRequired = true } session.ended = true + // Nothing can echo for this child any more; the journal's pending-submission + // recovery is what settles the sends these were armed for. + session.dispatchEchoes.clear() session.backgroundTasks.clear() input.onBackgroundTasksChanged?.(input.sessionId, null) session.unbindReadingControl?.() diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 4e9abfc17b5..0a8bf41688c 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -1,3 +1,4 @@ +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { describe, expect, it, vi } from 'vitest' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' @@ -34,7 +35,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi options: new Map(), reportedOptions: { model: 'gpt-live', effort: 'high' }, fastModeTierByModel: new Map(), - turnIdWaiters: [], + dispatchEchoes: createCodexDispatchEchoes(), translator: null } } diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 625e222ecfb..df66d436bf1 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -1,4 +1,7 @@ -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' import { randomUUID } from 'node:crypto' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import type { @@ -6,6 +9,7 @@ import type { openCodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' @@ -58,6 +62,12 @@ export type CodexStructuredSessionAdapterDeps = { sessionId: string, state: AgentSessionBackgroundTaskState | null ) => void + /** Identity for a send admitted earlier, once Codex echoes the user message. */ + onDispatchSettledLate?: (input: { + sessionId: string + clientMessageId: string + providerIdentity: AgentJournalItemIdentity + }) => void openConnection?: typeof openCodexAppServerConnection readProcessStartTime?: (pid: number) => Promise mintLinkId?: () => string @@ -94,7 +104,8 @@ export type CodexSession = { } /** Exact provider-advertised Fast request value for each discovered model. */ fastModeTierByModel: Map - turnIdWaiters: ((turnId: string) => void)[] + /** Sends whose identity is still to be settled by the provider echo. */ + dispatchEchoes: CodexDispatchEchoes translator: CodexJournalTranslator | null /** Ephemeral roster behind the background-tasks strip; never durable state. */ backgroundTasks: CodexBackgroundTaskTracker diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index e6a53925fc6..c88370adf05 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -6,21 +6,16 @@ import { type CodexAppServerConnection } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' -import { readCodexTurnId } from './codex-structured-thread-facts' -import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { DISPATCH_REJECTED_CODEX_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' -// Starting a Codex turn and learning its id, which are not the same event: -// `turn/start` returns the id on newer builds and acks before it exists on -// older ones, where it arrives as a `turn/started` notification instead. - -/** Codex records the user message first in a turn, so the submission Orca just - * accepted is ordinal 0 of `(threadId, turnId)`. */ -export const CODEX_USER_MESSAGE_ORDINAL = 0 - -/** Past this the turn is real but unnameable, which the journal renders as - * delivery unconfirmed rather than failure. */ -const TURN_ID_WAIT_MS = 10_000 +// Writing a Codex turn and learning which message landed where, which are not +// the same event. `turn/start` answers as soon as Codex owns the message, but a +// message issued while a turn is running is COALESCED into that turn: the same +// turn id comes back, no second `turn/started` fires, and the user message is +// echoed only when the running turn reaches it. So the response proves +// admission and nothing about identity, which the echo settles later. /** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise * become an arbitrary client-controlled `turn/start` parameter. */ @@ -38,16 +33,14 @@ export function isCodexTurnOptionKey(key: string): boolean { return CODEX_TURN_OPTION_KEYS.has(key) } -/** The session state one turn needs. `turnIdWaiters` is shared with the - * notification handler, which resolves the head of the queue — correct because - * Codex runs one turn per thread, so starts and `turn/started` share an order. */ +/** The session state one turn needs. */ export type CodexTurnHost = { connection: Pick threadId: string options: Map reportedOptions?: { model?: string } fastModeTierByModel: ReadonlyMap - turnIdWaiters: ((turnId: string) => void)[] + dispatchEchoes: CodexDispatchEchoes } function turnInputFor(body: AgentJournalMessageItem): Record[] { @@ -92,69 +85,54 @@ function codexTurnOptions(host: CodexTurnHost): Record { } /** - * Resolves the turn id, or null when Codex owns a turn it never named. Throws - * only for outcomes the wire must not read as acceptance. + * Hands one submission to Codex. False means the bounded correlation window + * refused it before the write; otherwise resolves when Codex has taken it. */ export async function startCodexTurn( host: CodexTurnHost, input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number } -): Promise { - // Registered BEFORE the call: on builds that ack first, `turn/started` can - // land while the response is still in flight. - let notified: ((turnId: string) => void) | null = null - const fromNotification = new Promise((resolve) => { - notified = resolve - host.turnIdWaiters.push(resolve) - setTimeout(() => resolve(null), TURN_ID_WAIT_MS).unref?.() - }) - try { - const started = await host.connection.request( - 'turn/start', - { - threadId: host.threadId, - clientUserMessageId: input.clientMessageId, - input: turnInputFor(input.body), - ...codexTurnOptions(host) - }, - { timeoutMs: input.timeoutMs } - ) - return readCodexTurnId(started) ?? (await fromNotification) - } finally { - const index = notified ? host.turnIdWaiters.indexOf(notified) : -1 - if (index !== -1) { - host.turnIdWaiters.splice(index, 1) - } +): Promise { + // Armed before the write: the echo can land while the response is in flight. + if (!host.dispatchEchoes.arm(input.clientMessageId)) { + return false } + await host.connection.request( + 'turn/start', + { + threadId: host.threadId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(input.body), + ...codexTurnOptions(host) + }, + { timeoutMs: input.timeoutMs } + ) + return true } /** - * One submission's outcome as the wire must read it: accepted names the turn, - * rejected is Codex answering and declining, and unknown covers a turn that is - * real but unnameable — never a failure the user is told their message hit. + * One submission's outcome as the wire must read it: admitted means Codex owns + * the message and its identity settles on the echo, rejected is Codex answering + * and declining. Elapsed time is never evidence here, because the wait a + * coalesced send would face is bounded only by the running turn. */ export async function dispatchCodexTurn( session: CodexTurnHost, input: { clientMessageId: string; body: AgentJournalMessageItem }, timeoutMs: number | undefined ): Promise { - let turnId: string | null try { - turnId = await startCodexTurn(session, { ...input, timeoutMs }) + if (!(await startCodexTurn(session, { ...input, timeoutMs }))) { + return { state: 'rejected', reason: DISPATCH_REJECTED_CODEX_QUEUE_FULL } + } } catch (error) { if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { + // Codex answered and declined, so no echo for this write can arrive. + session.dispatchEchoes.disarm(input.clientMessageId) return { state: 'rejected', reason: (error as Error).message } } + // A timeout or transport failure can happen after the frame was written. + // Keep the correlation armed so a later echo can prove delivery. throw error } - return turnId === null - ? { state: 'unknown', reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED } - : { - state: 'accepted', - providerIdentity: { - provider: 'codex', - threadId: session.threadId, - turnId, - ordinal: CODEX_USER_MESSAGE_ORDINAL - } - } + return { state: 'admitted' } } diff --git a/src/main/codex/codex-turn-ordinals.ts b/src/main/codex/codex-turn-ordinals.ts index 89ed72666db..7e21b900d96 100644 --- a/src/main/codex/codex-turn-ordinals.ts +++ b/src/main/codex/codex-turn-ordinals.ts @@ -3,6 +3,10 @@ import { digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' +/** Codex records the user message first in a turn, so a restored submission is + * ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 + /** Maximum forgotten turn keys retained for late-frame reconciliation. */ export const MAX_CODEX_TURN_ORDINAL_ENTRIES = 256 export const MAX_CODEX_TURN_ORDINAL_BYTES = 512 * 1024 diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index c8d48ef336b..d142672f8e5 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -16,7 +16,6 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from './journal-dispatch-doubt-reasons' import { dispatchWriteFailureReason } from '../../../shared/structured-agent-session-dispatch-rejection' import { digestPayload } from './journal-payload-bounds' import { @@ -55,6 +54,8 @@ function userMessage(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } +const LEGACY_CODEX_TURN_UNNAMED = 'codex app-server started a turn it did not name in time' + const journals = createTrackedJournalOpener() async function open() { @@ -196,6 +197,8 @@ describe('crash between provider accept and journal commit', () => { expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) }) + // Only an older Orca minted this reason -- Codex now settles a send on the + // provider echo -- but rows written under it still come back from disk. it('keeps a codex turn it could not name in doubt, never rejected', async () => { const journal = await open() await journal.appendSubmission({ @@ -207,7 +210,7 @@ describe('crash between provider accept and journal commit', () => { await journal.resolveDispatch({ clientMessageId: 'cm_codex_unnamed', state: 'unknown', - reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED, + reason: LEGACY_CODEX_TURN_UNNAMED, fence: 1 }) @@ -218,7 +221,7 @@ describe('crash between provider accept and journal commit', () => { // and it may never become a rejection, which would license a re-delivery. expect(restarted.submissions()[0]).toMatchObject({ dispatchState: 'unknown', - reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED, + reason: LEGACY_CODEX_TURN_UNNAMED, recovered: true }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index dbd56132100..206bd19e3ef 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -23,13 +23,6 @@ export const DISPATCH_DOUBT_PERSISTENCE_FAILED = 'dispatch_result_persistence_fa /** The operation tombstone survived recovery but its journal submission did not. */ export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missing' -/** Codex owns a turn it started but did not name, because its turn-start still - * settles on a deadline. The turn IS running, so this must never be treated as - * proof of non-delivery. Delete it once Codex settles on the app-server's - * turn-start response instead. */ -export const DISPATCH_DOUBT_CODEX_TURN_UNNAMED = - 'codex app-server started a turn it did not name in time' - /** The SDK took the frame, but its input pump did not prove whether the write completed. */ export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index c0b56aa7d14..477ed785918 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -8,7 +8,6 @@ import { structuredAgentSessionPayloadFingerprint } from '../../../shared/struct import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' const journals = createTrackedJournalOpener() @@ -39,7 +38,10 @@ describe('structured send idempotency', () => { it.each([ ['a refused write', 'provider_write_failed: broken pipe'], ['a dead host', 'host_restarted_before_acknowledgement'], - ['a codex turn it could not name', DISPATCH_DOUBT_CODEX_TURN_UNNAMED] + [ + 'a codex turn an older Orca could not name', + 'codex app-server started a turn it did not name in time' + ] ])('never puts an unknown back on the wire after %s', async (_case, reason) => { const body: AgentJournalMessageItem = { kind: 'message', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index e2f294f40f7..f2d1ab12230 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -6,7 +6,10 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import type { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_DOUBT_PROVIDER_EXITED, + DISPATCH_DOUBT_SUBMISSION_MISSING +} from '../agent-session-journal/journal-dispatch-doubt-reasons' import { accepted, attach, @@ -169,13 +172,15 @@ describe('send', () => { expect(state.ok && state.page.submissions).toHaveLength(2) }) - it('refuses to redeliver a retry for a turn the provider already owns', async () => { + it('refuses to redeliver a retry for a message the provider may already hold', async () => { await attach() + // A dead child ends the wait without proving non-delivery: the message was + // already written to that child's stdin. dispatch.mockImplementationOnce(async () => ({ state: 'unknown' as const, - reason: 'codex app-server started a turn it did not name in time' + reason: DISPATCH_DOUBT_PROVIDER_EXITED })) - const body = hostTestMessage('a turn codex owns but did not name') + const body = hostTestMessage('a message the provider may already hold') const params = { envelope: envelope('agentSession.send', { body }), body } const first = await host.send(CALLER, params) @@ -183,8 +188,8 @@ describe('send', () => { ok: true, value: { submission: { dispatchState: 'unknown' } } }) - // The turn is running; a second delivery would be a duplicate, so Retry - // replays the recorded outcome instead of re-sending. + // No `unknown` is re-delivered under its own id, whatever its reason says, + // so Retry replays the recorded outcome instead of writing again. await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index fe08e5f808d..91bf1158e07 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -42,8 +42,8 @@ describe('structured mailbox pointer host', () => { // The defect this pins: a running turn is announced by ONE lifecycle item, and settlement // tombstones it rather than rewriting it. A long tool-calling turn pushes that item arbitrarily // far from the tail, so any page-sized read reports a busy worker as idle — and the pointer is - // then delivered mid-turn, which Codex answers with `turn already running` and Claude settles - // `unknown` while the message is really queued. + // then delivered mid-turn, which Codex coalesces into the running turn and Claude queues behind + // it -- either way folded into work already in flight rather than read as a new instruction. const items = [runningTurn(), ...transcript(500)] hostRef.current = { journalSnapshot: () => ({ items }) } expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index 272d7799947..dd8ccf64f71 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -81,11 +81,14 @@ export function structuredSessionGateFacts( * Decide whether the nudge may be sent right now. * * Mid-turn delivery is refused for both providers rather than delegated to - * them: Codex answers a mid-turn `turn/start` with `turn already running`, and - * Claude accepts the frame but cannot acknowledge it inside the dispatch ack - * window, settling `unknown` while the message is really queued. Waiting for - * the turn to settle is the one contract that holds for both, and it preserves - * orchestration's existing idle-edge-only delivery policy. + * them. Neither refuses the frame: Codex COALESCES a mid-turn `turn/start` into + * the running turn -- measured on codex-cli 0.147.0, 0.150.1 and 0.153.4, none + * of which refuse it and none of which fire a second `turn/started` -- and + * Claude queues it behind the turn. Both therefore + * fold the nudge into work already in flight, where it reads as part of the + * running turn rather than a new instruction. Waiting for the turn to settle is + * the one contract that holds for both, and it preserves orchestration's + * existing idle-edge-only delivery policy. */ export function decideStructuredPointerDelivery(input: { refusal: AgentSessionPtyWriteRefusal diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index 8abad118de7..168bd870118 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -49,8 +49,8 @@ export function listAddressableStructuredWorkers(): OrchestrationAddressableAgen * A structured worker's agent status, in the vocabulary `@idle` already matches on. * * Null when the session cannot be read: unknown must not read as idle, or a broadcast to `@idle` - * would wake a worker mid-turn — which Codex answers with `turn already running` and Claude queues - * behind the running turn. + * would wake a worker mid-turn — which Codex coalesces into the running turn and Claude queues + * behind it. */ export function structuredWorkerAgentStatus(sessionId: string): string | null { const facts = readStructuredSessionGateFacts(sessionId) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index 990aa293ee4..4fa390be0fb 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -17,7 +17,10 @@ import type { } from '../codex/codex-app-server-connection' import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' @@ -37,10 +40,16 @@ const SESSION = 'session-integration-1' const THREAD = 'thread-integration' const TURN = 'turn-1' const WORKSPACE = 'workspace-1' +// The capability set the desktop renderer advertises. Without the pending-send +// one the host holds the reply until the send settles, which is a shim for +// clients too old to render a pending bubble — not what this suite models. const CLIENT = { clientId: 'device-a', clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] } // ─── the fake `codex app-server` ──────────────────────────────────────────── diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index f5a59033d73..219b132566e 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -16,8 +16,14 @@ import type { openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../shared/agent-session-journal-types' import type { AgentSessionHistoryResult, AgentSessionSubscribeEvent @@ -43,10 +49,16 @@ const SESSION = 'session-integration-1' const THREAD = 'thread-integration' const TURN = 'turn-1' const WORKSPACE = 'workspace-1' +// The capability set the desktop renderer advertises. Without the pending-send +// one the host holds the reply until the send settles, which is a shim for +// clients too old to render a pending bubble — not what this suite models. const CLIENT = { clientId: 'device-a', clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] } // ─── the fake `codex app-server` ──────────────────────────────────────────── @@ -269,6 +281,13 @@ function textOf(item: AgentJournalRenderItem): string { : '' } +/** The durable submission row, which settlement rewrites after the send returns. */ +function submissionOf(clientMessageId: string): AgentJournalSubmission | undefined { + return getStructuredAgentSessionHost() + ?.journalSnapshot(SESSION) + .submissions.find((entry) => entry.clientMessageId === clientMessageId) +} + async function historyPage( direction: 'tail' | 'before' | 'after', extra: Record = {} @@ -438,18 +457,25 @@ describe('a structured codex session over agentSession.*', () => { envelope: envelope('agentSession.send', { body }, created.fence), body }) - expect(sent.submission).toMatchObject({ - dispatchState: 'accepted', - providerItemId: `codex:${THREAD}:${TURN}:0` - }) + // Admission, not identity. `turn/start` proves Codex owns the message, but a + // send coalesced into a running turn is answered with that turn's id, so + // which message landed where is knowable only from the echo. + expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) expect(codex.live().calls.at(-1)).toMatchObject({ method: 'turn/start', params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId } }) codex.notify('turn/started', { turn: { id: TURN } }) + // Codex echoes the message back carrying the `clientId` it was sent under, + // which is the only thing that names which submission this row settles. codex.notify('item/completed', { - item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] } + item: { + type: 'userMessage', + id: 'item-0', + clientId: sent.clientMessageId, + content: [{ type: 'text', text: 'hi' }] + } }) codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello.' }) @@ -459,6 +485,15 @@ describe('a structured codex session over agentSession.*', () => { await drainStreamedEvents() expect(itemsOf(stream).map(textOf).filter(Boolean)).toEqual(['hi', 'Hello.']) + // The echo is the first item of this turn, so the settled key is ordinal 0 — + // minted by the same `identityFor` a history replay computes with, rather + // than guessed from the turn/start response. + await vi.waitFor(() => + expect(submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + ) }) it('runs create → send → stream → approval → cancel → reconnect → page history', async () => { @@ -513,12 +548,10 @@ describe('a structured codex session over agentSession.*', () => { envelope: envelope('agentSession.send', { body }, fence), body }) - // Codex named the turn, so the submission is accepted rather than - // "delivery unconfirmed", and adopts the provider's own item identity. - expect(sent.submission).toMatchObject({ - dispatchState: 'accepted', - providerItemId: `codex:${THREAD}:${TURN}:0` - }) + // Codex took the message, so the submission is pending rather than + // "delivery unconfirmed" — it carries no identity yet, because the response + // to a coalesced send names the running turn rather than this message. + expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) expect(codex.live().calls.at(-1)).toMatchObject({ method: 'turn/start', params: { @@ -531,14 +564,28 @@ describe('a structured codex session over agentSession.*', () => { // ── stream ────────────────────────────────────────────────────────────── codex.notify('turn/started', { turn: { id: TURN } }) - // Codex echoes the user message back as ordinal 0 of the turn. That is the - // key the submission adopted, so the echo has to reconcile into the bubble - // the client already has rather than append a second copy of it. + // Codex echoes the user message back as ordinal 0 of the turn, carrying the + // `clientId` it was sent under. That echo settles the submission's identity, + // and has to reconcile into the bubble the client already has rather than + // append a second copy of it. codex.notify('item/completed', { - item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'list files' }] } + item: { + type: 'userMessage', + id: 'item-0', + clientId: sent.clientMessageId, + content: [{ type: 'text', text: 'list files' }] + } }) await drainStreamedEvents() expect(itemsOf(stream).filter((item) => textOf(item) === 'list files')).toHaveLength(1) + // Settled from the echo's own journal identity, so it is by construction the + // key a replay recomputes for this row. + await vi.waitFor(() => + expect(submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + ) codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Two ' }) diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 506a45ae821..7a3736b9009 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -121,9 +121,13 @@ describe('structured session runtime provider-exit wiring', () => { }) } + // `pending` is this send's real answer now, not a weaker one: admission settles + // when the transport takes the frame, and identity arrives later on the + // provider's echo. What proves the message reached the REACQUIRED provider is + // the turn it starts below, which is what this test exists to check. await expect( host.send({ callerKey: 'runtime-test' }, { envelope, body }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) expect(turn).toBe(1) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index fbc13b58fca..e437739a58b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -7,6 +7,7 @@ // reads is module-level for the same reason the registry is — the runtime // service is already far past its size budget. +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { existsSync } from 'node:fs' import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -246,6 +247,18 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + void host?.settleLateDispatch(settlement).catch((error) => + deps.onError?.({ + scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, + error + }) + ) + } const codex = new CodexStructuredSessionAdapter({ resolveLaunch: createCodexStructuredLaunchResolver({ store, @@ -257,6 +270,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishBackgroundTaskState(sessionId, state), + onDispatchSettledLate, onEvent: (event) => { if (event.type !== 'ended' || !('cause' in event) || event.cause !== 'unexpected-exit') { return @@ -298,14 +312,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishBackgroundTaskState(sessionId, state), - onDispatchSettledLate: (settlement) => { - void host?.settleLateDispatch(settlement).catch((error) => - deps.onError?.({ - scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, - error - }) - ) - }, + onDispatchSettledLate, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) }) diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 9ea733e5832..0b2aecfb67e 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -20,8 +20,11 @@ export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' -/** Local admission refused the frame before any transport was involved. */ +/** Local admission refused the frame before any transport was involved. Two + * strings rather than one provider-neutral marker because both are already + * durable journal reasons; rewording either would relabel rows on disk. */ export const DISPATCH_REJECTED_QUEUE_FULL = 'claude structured dispatch queue is full' +export const DISPATCH_REJECTED_CODEX_QUEUE_FULL = 'codex structured dispatch queue is full' export function dispatchWriteFailureReason(error: unknown): string { const detail = error instanceof Error ? error.message : String(error) @@ -45,6 +48,8 @@ export function dispatchRejectionWasTransportWriteFailure( */ export function dispatchRejectionReasonIsInternal(reason: string | null | undefined): boolean { return ( - dispatchRejectionWasTransportWriteFailure(reason) || reason === DISPATCH_REJECTED_QUEUE_FULL + dispatchRejectionWasTransportWriteFailure(reason) || + reason === DISPATCH_REJECTED_QUEUE_FULL || + reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL ) } From 59d29af402855dd5d1ab60c253cf0f5cf4f56487 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:43:51 -0700 Subject: [PATCH 16/43] test: add a verified OMP native-chat mock scenario (#20655) Co-authored-by: plotarmordev --- mobile/README.md | 1 + .../mock-server-native-chat-scenario.ts | 75 ++++++++++++++++--- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 64f1081b73c..e78c2c410bf 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -184,6 +184,7 @@ Connect from the app using endpoint `ws://localhost:6768` and token `mock-device ### Environment variables - `MOCK_NATIVE_CHAT=1` — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures. +- `MOCK_CHAT_AGENT=omp` — with `MOCK_NATIVE_CHAT=1`, present an OMP tab and four decoded transcript messages, including a tool call and result, instead of the default Claude scenario. It deliberately omits `transcriptPath` to exercise legacy-hook readability discovery; current OMP hooks may report a path. - `MOCK_SERVER_KEY_FILE` — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair. ### Scenario control files diff --git a/mobile/scripts/mock-server-native-chat-scenario.ts b/mobile/scripts/mock-server-native-chat-scenario.ts index d2f65e0b635..b9d18348f7b 100644 --- a/mobile/scripts/mock-server-native-chat-scenario.ts +++ b/mobile/scripts/mock-server-native-chat-scenario.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { WebSocket } from 'ws' import type { AgentStatusEntry } from '../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../src/shared/native-chat-types' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTerminalClientTab @@ -25,6 +26,10 @@ const TAB_ID = 'chat-tab-1' const SESSION_ID = 'mock-chat-session' const TRANSCRIPT_PATH = join(tmpdir(), 'mock-transcript.jsonl') const MOCK_IMAGE_PATH = join(tmpdir(), 'mock-image.png') +// Exercise legacy OMP hooks without a transcript path; current hooks may include one. +const CHAT_AGENT = process.env.MOCK_CHAT_AGENT === 'omp' ? 'omp' : 'claude' +const CHAT_TITLE = CHAT_AGENT === 'omp' ? 'OMP' : 'Claude Code' +const TRANSCRIPT_START = Date.now() - 1000 * 60 * 5 function readControl(file: string): string { try { @@ -41,28 +46,27 @@ const agentStatus: AgentStatusEntry = { prompt: '', updatedAt: Date.now(), stateStartedAt: Date.now(), - agentType: 'claude', + agentType: CHAT_AGENT, paneKey: `${TAB_ID}:leaf-1`, terminalHandle: TERMINAL_HANDLE, stateHistory: [], - providerSession: { - key: 'session_id', - id: SESSION_ID, - transcriptPath: TRANSCRIPT_PATH - } + providerSession: + CHAT_AGENT === 'omp' + ? { key: 'session_id', id: SESSION_ID } + : { key: 'session_id', id: SESSION_ID, transcriptPath: TRANSCRIPT_PATH } } function buildTab(): RuntimeMobileSessionTerminalClientTab { return { type: 'terminal', id: TAB_ID, - title: 'Claude Code', + title: CHAT_TITLE, parentTabId: TAB_ID, leafId: 'leaf-1', ptyId: 'pty-1', status: 'ready', terminal: TERMINAL_HANDLE, - launchAgent: 'claude', + launchAgent: CHAT_AGENT, agentStatus, viewMode: 'chat', isActive: true @@ -104,6 +108,51 @@ function worktreeOf(request: RpcRequest): string { return typeof raw === 'string' ? raw : 'id:mock-worktree' } +// Why: shapes mirror what the runtime's omp decoder emits for a real session +// (thinking→text on the assistant turn, toolCall blocks, toolResult turns), so +// the phone exercises the same render path a live omp pane would. +function mockTranscript(): NativeChatMessage[] { + if (CHAT_AGENT !== 'omp') { + return [] + } + const t = TRANSCRIPT_START + return [ + { + id: 'omp-1', + role: 'user', + blocks: [{ type: 'text', text: 'why is my deploy failing?' }], + timestamp: t, + source: 'transcript' + }, + { + id: 'omp-2', + role: 'assistant', + blocks: [ + { type: 'text', text: 'Let me check the deploy logs first.' }, + { type: 'tool-call', name: 'bash', input: { command: 'kubectl get pods' } } + ], + timestamp: t + 1000, + source: 'transcript' + }, + { + id: 'omp-3', + role: 'tool', + blocks: [{ type: 'tool-result', output: 'api-7f9c 0/1 CrashLoopBackOff' }], + timestamp: t + 2000, + source: 'transcript' + }, + { + id: 'omp-4', + role: 'assistant', + blocks: [ + { type: 'text', text: 'The API pod is crash-looping. Check its logs with kubectl logs.' } + ], + timestamp: t + 3000, + source: 'transcript' + } + ] +} + // Why: unsubscribe correlates by worktree, not request id, and a socket that // navigates A->B->A would otherwise stack one push loop per subscribe. const tabsPushLoops = new Map>>() @@ -144,7 +193,7 @@ type Respond = (response: RpcResponse) => void type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse type Failure = (id: string, code: string, message: string) => RpcResponse -/** Mock backend for the native-chat surface: session tabs, an empty transcript +/** Mock backend for the native-chat surface: session tabs, a fixture transcript * snapshot, terminal send, and image upload. Opt-in via MOCK_NATIVE_CHAT=1 * because it replaces the default terminal fixtures. No transcript or terminal * output frames are pushed. Returns false for methods it does not own. */ @@ -194,7 +243,7 @@ export function handleMockNativeChatRequest( const entry = (handle: string) => ({ handle, worktreeId, - title: 'Claude Code', + title: CHAT_TITLE, isActive: true, hasRunningProcess: true }) @@ -209,11 +258,13 @@ export function handleMockNativeChatRequest( } case 'nativeChat.subscribe': - respond(success(request.id, { type: 'snapshot', messages: [], hasMore: false }, true)) + respond( + success(request.id, { type: 'snapshot', messages: mockTranscript(), hasMore: false }, true) + ) return true case 'nativeChat.readSession': - respond(success(request.id, { messages: [], hasMore: false })) + respond(success(request.id, { messages: mockTranscript(), hasMore: false })) return true case 'terminal.subscribe': { From 3632311d0b8ae24a9bc5a9d50bb66ca26b25dd18 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:56:18 -0700 Subject: [PATCH 17/43] fix(omp): preserve status after terminal title owner rewrite (#20610) Validated and independently reviewed OMP integration fix. Co-authored-by: shahidbeig-a11y <258701601+shahidbeig-a11y@users.noreply.github.com> --- .../omp-native-title-win32.meta.json | 13 +++++ .../__fixtures__/omp-native-title-win32.txt | 1 + .../worktree-title-derived-agent-rows.test.ts | 20 ++++++++ .../terminal-title-tracker-parity.test.ts | 14 +++++ src/shared/agent-title-identity.ts | 5 ++ src/shared/agent-title-owner.ts | 5 ++ src/shared/omp-owner-state-title.test.ts | 51 +++++++++++++++++++ src/shared/pi-compatible-synthetic-title.ts | 6 +++ src/shared/pi-state-title-marker.ts | 31 +++++++++-- tests/e2e/omp-title-marker.spec.ts | 43 ++++++++++++++++ tests/tools/omp-native-title-capture.mjs | 18 +++++++ 11 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/__fixtures__/omp-native-title-win32.meta.json create mode 100644 src/main/runtime/__fixtures__/omp-native-title-win32.txt create mode 100644 src/shared/omp-owner-state-title.test.ts create mode 100644 tests/e2e/omp-title-marker.spec.ts create mode 100644 tests/tools/omp-native-title-capture.mjs diff --git a/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json new file mode 100644 index 00000000000..8594baa7630 --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json @@ -0,0 +1,13 @@ +{ + "capturedAt": "2026-09-14T11:25:01.730Z", + "platform": "darwin", + "command": [ + "bun", + "tests/tools/omp-native-title-capture.mjs", + "" + ], + "cols": 100, + "rows": 30, + "note": "OMP source ne7546987ca526eac8f605fac19ef9805b8f01898 buildTerminalTitleWithState; explicit win32 argument on macOS PTY, synthetic state transitions, no model/account. Not a Windows runtime capture.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/omp-native-title-win32.txt b/src/main/runtime/__fixtures__/omp-native-title-win32.txt new file mode 100644 index 00000000000..ac67e688dce --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-native-title-win32.txt @@ -0,0 +1 @@ +]0;π : Run a long task]0;π : release | π : note | OMP ! action required ✦]0;π > Run a long task]0;π > release | π : note | OMP ! action required ✦]0;π ! Run a long task]0;π ! release | π : note | OMP ! action required ✦ \ No newline at end of file diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index c5de2eb1813..b0e7340f373 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -90,6 +90,26 @@ describe('buildTitleDerivedAgentRows', () => { ]) }) + it.each([ + [':', 'working'], + ['>', 'idle'], + ['!', 'waiting'] + ])('retains hook-less OMP rows for owner marker %s', (marker, state) => { + const title = `OMP ${marker} Run a long task` + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'omp' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: title } }, + ptyIdsByTabId: { 'tab-1': ['pty-omp'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + expect(rows.map((row) => [row.agentType, row.state, row.entry.terminalTitle])).toEqual([ + ['omp', state, title] + ]) + }) + it('keeps Pi-compatible title-derived rows as Pi for launched Pi sessions', () => { const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1', { launchAgent: 'pi' })], diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts index 09126e91c6b..dac6bbaf326 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' // Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY // title tracker in main alongside the renderer transport's byte parser. Both // must derive IDENTICAL ordered title/status facts from the same bytes, or @@ -103,6 +104,19 @@ describe('main title tracker parity with the renderer transport processor', () = vi.useRealTimers() }) + it('agrees on captured OMP native frames before and after owner rebranding', () => { + const captured = readFileSync( + new URL('../../../../main/runtime/__fixtures__/omp-native-title-win32.txt', import.meta.url), + 'utf8' + ) + feedBoth(paths, captured) + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.some((event) => event.kind === 'became-working')).toBe(true) + expect(paths.main.events.some((event) => event.kind === 'became-idle')).toBe(true) + feedBoth(paths, captured.replaceAll(']0;π', ']0;OMP')) + expect(paths.main.events).toEqual(paths.renderer.events) + }) + it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => { // One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's // trailing idle title. A last-title reader sees only the idle title and diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 2b5194bfda8..949768e03b5 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -1,3 +1,4 @@ +import { getPiStateTitleBrand } from './pi-state-title-marker' import { AGY_AGENT_NAME_RE, CLAUDE_IDLE, @@ -67,6 +68,10 @@ function computeAgentLabel(title: string): string | null { ) { return 'Claude Code' } + const piStateBrand = getPiStateTitleBrand(title) + if (piStateBrand) { + return piStateBrand + } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index 2526c94572f..df0a668621a 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -1,3 +1,4 @@ +import { rebrandPiStateTitle } from './pi-state-title-marker' import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection' import type { AgentStatusEntry, AgentType } from './agent-status-types' import { @@ -157,6 +158,10 @@ export function normalizeCompatibleAgentTitleForOwner( ) { return title } + const stateTitle = rebrandPiStateTitle(title, ownerProfile.workingLabel) + if (stateTitle !== null) { + return stateTitle + } // Why: a π-branded title is the agent's own semantic session title (`π > - `; // Orca's injected extension writes the same shape). Swap only the BRAND for the owner's label // so the pane still reads as its launch owner (#6689, #7633, #9077) without discarding the diff --git a/src/shared/omp-owner-state-title.test.ts b/src/shared/omp-owner-state-title.test.ts new file mode 100644 index 00000000000..c06e4dbfe78 --- /dev/null +++ b/src/shared/omp-owner-state-title.test.ts @@ -0,0 +1,51 @@ +import { getPiCompatibleTitleSeparatorStatus } from './pi-compatible-synthetic-title' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection' +import { normalizeCompatibleAgentTitleForOwner } from './agent-title-owner' +import { clearPiStateWorkingMarker } from './pi-state-title-marker' + +const transcript = readFileSync( + join(__dirname, '..', 'main', 'runtime', '__fixtures__', 'omp-native-title-win32.txt'), + 'utf8' +) +// oxlint-disable-next-line no-control-regex -- The fixture retains actual OSC control bytes. +const titles = [...transcript.matchAll(/\x1b\]0;([^\x07]+)\x07/g)].map((match) => match[1]) + +describe('owner-rewritten OMP titles from captured upstream output', () => { + it('contains the six upstream state frames', () => expect(titles).toHaveLength(6)) + it.each( + titles.map((title, index) => ({ + title, + state: index < 2 ? 'working' : index < 4 ? 'idle' : 'permission' + })) + )('preserves $state and label for $title', ({ title, state }) => { + for (const prefix of ['', 'zsh | ', 'tmux: ']) { + const wrapped = prefix + title + expect(detectAgentStatusFromTitle(wrapped)).toBe(state) + const owned = normalizeCompatibleAgentTitleForOwner(wrapped, 'omp', { ownerIsLaunch: true }) + expect(owned).toBe(prefix + title.replace('π', 'OMP')) + expect(getAgentLabel(owned)).toBe('OMP') + expect(detectAgentStatusFromTitle(owned)).toBe(state) + expect(getPiCompatibleTitleSeparatorStatus(owned)).toBe(state) + expect(normalizeCompatibleAgentTitleForOwner(owned, 'omp')).toBe(owned) + expect(normalizeCompatibleAgentTitleForOwner(owned, 'pi')).toBe( + prefix + title.replace('π', 'Pi') + ) + if (state === 'working') { + expect(detectAgentStatusFromTitle(clearPiStateWorkingMarker(owned) ?? '')).toBe('idle') + } + } + }) + it.each([ + 'omp-harness ready', + '/tmp/OMP : file', + 'lowercase omp : note', + 'Pi: legacy', + 'OMP ready' + ])('does not rewrite neutral or legacy title %s as a working marker', (title) => { + expect(clearPiStateWorkingMarker(title)).toBeNull() + expect(detectAgentStatusFromTitle(title)).not.toBe('working') + }) +}) diff --git a/src/shared/pi-compatible-synthetic-title.ts b/src/shared/pi-compatible-synthetic-title.ts index 7b99235811d..0e9e5d2b196 100644 --- a/src/shared/pi-compatible-synthetic-title.ts +++ b/src/shared/pi-compatible-synthetic-title.ts @@ -1,3 +1,5 @@ +import { getPiStateTitleStatus } from './pi-state-title-marker' + export type PiCompatibleSyntheticAgentLabel = 'Pi' | 'OMP' export type PiCompatibleSyntheticAgentStatus = 'working' | 'permission' | 'idle' @@ -71,6 +73,10 @@ export function isLegacyPiCompatibleTitle(title: string): boolean { export function getPiCompatibleTitleSeparatorStatus( title: string ): PiCompatibleSyntheticAgentStatus | null { + const nativeState = getPiStateTitleStatus(title) + if (nativeState) { + return nativeState + } // Why: a spinner anywhere means the agent is working, and that outranks the separator — // the frame is drawn over the idle separator position while a turn runs. if (containsBrailleSpinner(title)) { diff --git a/src/shared/pi-state-title-marker.ts b/src/shared/pi-state-title-marker.ts index 7f6ece71bd6..3d2ed19a759 100644 --- a/src/shared/pi-state-title-marker.ts +++ b/src/shared/pi-state-title-marker.ts @@ -27,15 +27,17 @@ function escapeForCharacterClass(marker: string): string { return marker.replace(/[\\\]^-]/g, '\\$&') } -// Why: `π` must sit at a token boundary so wrapper prefixes of any shape (`zsh | π : cwd`, +// Why: the brand must sit at a token boundary so wrapper prefixes (`zsh | OMP : cwd`, // `tmux: π : cwd`) still expose the marker, and whitespace must separate the marker so the // legacy no-space `π: cwd` disabled title keeps its historical idle classification. const PI_STATE_TITLE_RE = new RegExp( - `(?:^|[\\s|])π[ \\t]+([${PI_STATE_MARKERS.map(escapeForCharacterClass).join('')}])(?=\\s|$)`, + `(?:^|[\\s|])(π|Pi|OMP)[ \\t]+([${PI_STATE_MARKERS.map(escapeForCharacterClass).join('')}])(?=\\s|$)`, 'u' ) type PiStateTitleMatch = { + brand: string + brandIndex: number marker: PiStateMarker markerIndex: number } @@ -49,8 +51,14 @@ function matchPiStateTitle(title: string): PiStateTitleMatch | null { if (!match) { return null } + const marker = match[2] + if (marker !== ':' && marker !== '!' && marker !== '>') { + return null + } return { - marker: match[1] as PiStateMarker, + brand: match[1], + brandIndex: match.index + match[0].indexOf(match[1]), + marker, markerIndex: match.index + match[0].length - 1 } } @@ -73,3 +81,20 @@ export function clearPiStateWorkingMarker(title: string): string | null { } return `${title.slice(0, match.markerIndex)}${PI_IDLE_MARKER}${title.slice(match.markerIndex + 1)}` } + +/** The state marker owns identity too; its label may mention another agent. */ +export function getPiStateTitleBrand(title: string): 'Pi' | 'OMP' | null { + const match = matchPiStateTitle(title) + return match ? (match.brand === 'OMP' ? 'OMP' : 'Pi') : null +} + +/** Rebrand only the protocol prefix, preserving wrappers and the opaque session label. */ +export function rebrandPiStateTitle(title: string, brand: string): string | null { + const match = matchPiStateTitle(title) + if (!match) { + return null + } + return ( + title.slice(0, match.brandIndex) + brand + title.slice(match.brandIndex + match.brand.length) + ) +} diff --git a/tests/e2e/omp-title-marker.spec.ts b/tests/e2e/omp-title-marker.spec.ts new file mode 100644 index 00000000000..4f2db0ddbd9 --- /dev/null +++ b/tests/e2e/omp-title-marker.spec.ts @@ -0,0 +1,43 @@ +import { writeFile } from 'node:fs/promises' +import { buildShellCommandFromArgv } from '../../src/shared/tui-agent-startup-shell' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +test('OMP spaced-colon title renders working and clears on idle', async ({ + orcaPage +}, testInfo) => { + test.skip( + process.platform === 'win32', + 'POSIX title replay; Windows formatter bytes have separate coverage' + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + const script = testInfo.outputPath('title-replay.cjs') + await writeFile( + script, + ` +process.stdout.write('\\x1b]0;OMP : Image review\\x07') +process.stdin.on('data', () => process.stdout.write('\\x1b]0;OMP > Image review\\x07')) +` + ) + await execInTerminal( + orcaPage, + ptyId, + buildShellCommandFromArgv([process.execPath, script], 'posix') + ) + const working = orcaPage.locator('[aria-label="Working"]') + await expect(working.first()).toBeVisible({ timeout: 15000 }) + await orcaPage.screenshot({ path: testInfo.outputPath('omp-title-working.png') }) + await sendToTerminal(orcaPage, ptyId, '\r') + await expect(working).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('omp-title-idle.png') }) +}) diff --git a/tests/tools/omp-native-title-capture.mjs b/tests/tools/omp-native-title-capture.mjs new file mode 100644 index 00000000000..9f4be2cc7c0 --- /dev/null +++ b/tests/tools/omp-native-title-capture.mjs @@ -0,0 +1,18 @@ +// Run under Bun through capture-agent-pty-transcript.mjs; sourceRoot is read-only. +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const sourceRoot = process.argv[2] +if (!sourceRoot) { + throw new Error('Expected path to the read-only oh-my-pi checkout') +} +const { buildTerminalTitleWithState } = await import( + pathToFileURL(resolve(sourceRoot, 'packages/coding-agent/src/utils/title-generator.ts')).href +) +for (const state of ['working', 'idle', 'attention']) { + for (const label of ['Run a long task', 'release | π : note | OMP ! action required ✦']) { + // Exercise upstream's explicit Windows argument, independently of the capture host OS. + const title = buildTerminalTitleWithState(label, state, 0, true, 'win32') + process.stdout.write(`\x1b]0;${title}\x07`) + } +} From fc4519cda4b5a91d3bd511daab7c292de2bdf8a1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:56:22 -0700 Subject: [PATCH 18/43] fix(omp): preserve zsh startup with global aliases (#20621) Validated and independently reviewed OMP integration fix. --- .github/workflows/unit-tests.yml | 1 + .../scripts/pr-workflow-parallelism.test.mjs | 1 + .../daemon-bash-rcfile.txt | 4 +-- .../daemon-zsh-zshenv.txt | 4 +-- .../local-bash-rcfile.txt | 4 +-- .../local-zsh-zshenv.txt | 4 +-- .../relay-bash-rcfile.txt | 4 +-- .../relay-zsh-zshenv.txt | 4 +-- .../omp-shell-wrapper-alias-safety.test.ts | 25 +++++++++++++++++++ src/main/pty/omp-shell-wrapper.ts | 4 +-- 10 files changed, 41 insertions(+), 14 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b21feae3230..490eda88c33 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -56,6 +56,7 @@ jobs: --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ + --exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ --exclude=src/main/shell-startup-feature-channel.test.ts \ --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index ed4e1b1f1c8..d18837a1573 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -15,6 +15,7 @@ const shellContractFiles = [ 'src/main/daemon/shell-ready.test.ts', 'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', + 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index b79ed543494..536831230b4 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -39,8 +39,8 @@ __orca_restore_agent_teams_path # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 3d3403ad099..f86bd569381 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -77,8 +77,8 @@ __orca_deferred_init() { # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index dc14486cdb7..a11d3e6183e 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -42,8 +42,8 @@ __orca_restore_agent_teams_path # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 10e9e144fc0..35a262b5e02 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -77,8 +77,8 @@ __orca_deferred_init() { # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index de9c8f95248..61bdd01dd50 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -31,8 +31,8 @@ fi # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 394bc4a6d10..ff90115d30d 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -51,8 +51,8 @@ __orca_deferred_init() { # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/pty/omp-shell-wrapper-alias-safety.test.ts b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts index 3863b3aacaf..d1d0e0aad83 100644 --- a/src/main/pty/omp-shell-wrapper-alias-safety.test.ts +++ b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts @@ -68,3 +68,28 @@ describe.skipIf(process.platform === 'win32')('omp wrapper under a user alias na expectAliasedOmpNameSurvives('/bin/zsh', 'setopt aliases') }) }) + +describe.skipIf(process.platform === 'win32' || !zshAvailable)('OMP wrapper global aliases', () => { + it.each(['--help', '-v', 'models'])('parses with hostile global alias %s', (token) => { + const root = mkdtempSync(join(tmpdir(), 'orca-omp-global-alias-')) + roots.push(root) + const startup = join(root, 'startup.zsh') + writeFileSync( + startup, + [ + `alias -g -- ${token}='${token} 2>&1 | cat'`, + getPosixOmpShellWrapper(), + `if ! __orca_omp_should_skip_extension '${token}'; then exit 1; fi`, + 'printf "parsed\\n"', + `alias -g -- '${token}'` + ].join('\n') + ) + const result = spawnSync('/bin/zsh', ['-f', startup], { + encoding: 'utf8', + env: { ...process.env, HOME: root, ZDOTDIR: root } + }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('parsed') + expect(result.stdout).toContain('2>&1 | cat') + }) +}) diff --git a/src/main/pty/omp-shell-wrapper.ts b/src/main/pty/omp-shell-wrapper.ts index f5bc25421bb..de1d9bed2af 100644 --- a/src/main/pty/omp-shell-wrapper.ts +++ b/src/main/pty/omp-shell-wrapper.ts @@ -40,13 +40,13 @@ const OMP_SUBCOMMANDS = [ ] as const export function getPosixOmpShellWrapper(): string { - const subcommands = OMP_SUBCOMMANDS.join('|') + const subcommands = OMP_SUBCOMMANDS.map((value) => `'${value}'`).join('|') return `# Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as \`omp config\` keep # their normal argv shape. __orca_omp_should_skip_extension() { case "\${1:-}" in - help|--help|-h|--version|-v) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; ${subcommands}) return 0 ;; esac return 1 From ee1a0a4e2d40e10f8aa069962380e79e671de784 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:56:25 -0700 Subject: [PATCH 19/43] fix(git): avoid Windows tree kills after the command has exited (#20606) Validated and independently reviewed OMP integration fix. --- .../git-command-termination-runtime.yml | 22 ++++ .../spawned-command-tree-kill.test.ts | 122 ++++++++++++++++++ .../spawned-command-tree-kill.ts | 5 + 3 files changed, 149 insertions(+) create mode 100644 .github/workflows/git-command-termination-runtime.yml create mode 100644 src/main/git/command-runner/spawned-command-tree-kill.test.ts diff --git a/.github/workflows/git-command-termination-runtime.yml b/.github/workflows/git-command-termination-runtime.yml new file mode 100644 index 00000000000..1602bae956a --- /dev/null +++ b/.github/workflows/git-command-termination-runtime.yml @@ -0,0 +1,22 @@ +name: Git command termination runtime +on: + pull_request: + paths: + - 'src/main/git/command-runner/spawned-command-tree-kill*' + - '.github/workflows/git-command-termination-runtime.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + windows-exit: + runs-on: windows-latest + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Verify exited native child does not trigger taskkill + run: node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/git/command-runner/spawned-command-tree-kill.test.ts diff --git a/src/main/git/command-runner/spawned-command-tree-kill.test.ts b/src/main/git/command-runner/spawned-command-tree-kill.test.ts new file mode 100644 index 00000000000..758fb296227 --- /dev/null +++ b/src/main/git/command-runner/spawned-command-tree-kill.test.ts @@ -0,0 +1,122 @@ +import { ChildProcess } from 'node:child_process' +import { once } from 'node:events' +import { spawnProcess } from '../../../shared/child-process/run-process' +import type * as NodeChildProcess from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock, admitMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + admitMock: vi.fn(() => true) +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock +})) +vi.mock('../../own-chromium-tree-kill-guard', () => ({ + admitSelfInitiatedTreeKill: admitMock +})) + +import { killSpawnedCommandTree } from './spawned-command-tree-kill' + +const originalPlatform = process.platform + +function childWithPid(pid: number): ChildProcess { + const child = new ChildProcess() + Object.defineProperty(child, 'pid', { value: pid }) + vi.spyOn(child, 'kill').mockReturnValue(true) + vi.spyOn(child, 'unref').mockImplementation(() => {}) + return child +} + +describe('Git command tree termination', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + spawnMock.mockReset() + admitMock.mockReset().mockReturnValue(true) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + vi.restoreAllMocks() + }) + + it.each([0, 128])( + 'never taskkills a child that exited with code %i before close', + async (code) => { + const child = childWithPid(1234) + Object.defineProperty(child, 'exitCode', { value: code }) + + await killSpawnedCommandTree(child) + + expect(spawnMock).not.toHaveBeenCalled() + expect(admitMock).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledOnce() + } + ) + + it('never taskkills a child that exited by signal before close', async () => { + const child = childWithPid(1234) + Object.defineProperty(child, 'signalCode', { value: 'SIGTERM' }) + + await killSpawnedCommandTree(child) + + expect(spawnMock).not.toHaveBeenCalled() + expect(admitMock).not.toHaveBeenCalled() + }) + + it('still waits for tree termination when the Windows root has not exited', async () => { + const child = childWithPid(1234) + const killer = childWithPid(5678) + spawnMock.mockReturnValue(killer) + let settled = false + const pending = killSpawnedCommandTree(child).then(() => { + settled = true + }) + + await Promise.resolve() + expect(settled).toBe(false) + expect(spawnMock).toHaveBeenCalledWith('taskkill', ['/pid', '1234', '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + killer.emit('close', 0) + await pending + expect(child.kill).not.toHaveBeenCalled() + }) + + it('preserves handle termination on POSIX', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + const child = childWithPid(1234) + + await killSpawnedCommandTree(child) + + expect(child.kill).toHaveBeenCalledOnce() + expect(spawnMock).not.toHaveBeenCalled() + }) + it.skipIf(originalPlatform !== 'win32').each([0, 128])( + 'does not taskkill an actual native Windows child after exit %i', + async (exitCode) => { + const original = await vi.importActual('node:child_process') + spawnMock.mockImplementation((program, args, options) => { + if (program !== process.execPath) { + throw new Error('Unexpected external process in native exit probe') + } + return original.spawn(program, args, options) + }) + const child = spawnProcess({ + program: process.execPath, + args: ['-e', `process.exit(${exitCode})`] + }) + const closed = once(child, 'close') + await once(child, 'exit') + expect(child.exitCode).toBe(exitCode) + expect(child.pid).toBeGreaterThan(0) + spawnMock.mockClear() + await killSpawnedCommandTree(child) + expect(spawnMock).not.toHaveBeenCalled() + expect(admitMock).not.toHaveBeenCalled() + await closed + } + ) +}) diff --git a/src/main/git/command-runner/spawned-command-tree-kill.ts b/src/main/git/command-runner/spawned-command-tree-kill.ts index 324e04f1db3..035764d5249 100644 --- a/src/main/git/command-runner/spawned-command-tree-kill.ts +++ b/src/main/git/command-runner/spawned-command-tree-kill.ts @@ -9,6 +9,11 @@ export function killSpawnedCommandTree(child: ChildProcess): Promise { child.kill() return Promise.resolve() } + // Windows may reuse the pid after exit while inherited pipes still delay close. + if ((child.exitCode ?? null) !== null || (child.signalCode ?? null) !== null) { + child.kill() + return Promise.resolve() + } if ( !admitSelfInitiatedTreeKill({ pid, site: 'git-command-tree-kill', scope: 'win-taskkill-tree' }) ) { From 8d93505958e00492a82de4f464ce4adbcd081776 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:57:59 -0700 Subject: [PATCH 20/43] fix(terminal): retain renames before renderer pane hydration (#20619) * fix(terminal): retain renames before renderer pane hydration * test(terminal): keep late renames from recreating closed tabs --- ...runtime-resolve-worktree-removal-target.ts | 8 +- ...-runtime-terminal-rename-retention.test.ts | 92 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/orca-runtime-terminal-rename-retention.test.ts diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 0d934548332..44f4524c782 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -134,7 +134,13 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith return { handle, tabId: leaf.tabId, title } } } - return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, title } + const tabId = pty.pty.tabId ?? pty.record.tabId + // A notifier can exist before its pane graph; retain the rename on the known tab. + if (this.notifier?.renameTerminal && tabId) { + this.persistHeadlessTerminalTitle(pty.pty.worktreeId, tabId, title) + this.notifier.renameTerminal(tabId, title) + } + return { handle, tabId, title } } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) diff --git a/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts new file mode 100644 index 00000000000..87aabaf4c54 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts @@ -0,0 +1,92 @@ +import './orca-runtime-test-lifecycle.spec' +import type { RuntimeStore } from './runtime-store-contract' +import { describe, expect, it, vi } from 'vitest' +import { createMobileCreateTestNotifier } from './orca-runtime-test-scenario-builders.spec' +import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from './orca-runtime-test-fixtures.spec' + +describe('terminal rename before renderer graph hydration', () => { + it.each(['Media Engine Orch', null])( + 'persists and forwards title %s across PTY replacement', + async (title) => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + session.tabsByWorktree[TEST_WORKTREE_ID][0].customTitle = 'Previous name' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared fixture supplies RuntimeStore methods; its legacy Mock return type loses callable signatures. + const checkedStore = runtimeStore as RuntimeStore + const runtime = new OrcaRuntimeService(checkedStore) + const renameTerminal = vi.fn() + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'omp-initial-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal, + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + await runtime.renameTerminal(created.handle, title) + + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID][0].customTitle).toBe(title) + expect(renameTerminal).toHaveBeenCalledWith('host-tab', title) + runtime.onPtyExit('omp-initial-pty', 0) + const restored = new OrcaRuntimeService(checkedStore) + restored.setPtyController({ + spawn: vi.fn(async () => ({ id: 'omp-replacement-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await restored.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID][0].customTitle).toBe(title) + } + ) + it('does not recreate a closed persisted tab from a surviving PTY record', async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Shared fixture implements RuntimeStore; its legacy Mock typing loses callable signatures. + const runtime = new OrcaRuntimeService(runtimeStore as RuntimeStore) + const notifier = createMobileCreateTestNotifier(vi.fn()) + runtime.setNotifier(notifier) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'surviving-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + setSession({ ...getSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) + runtimeStore.setWorkspaceSession.mockClear() + + await runtime.renameTerminal(created.handle, 'Late rename') + + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(runtimeStore.setWorkspaceSession).not.toHaveBeenCalled() + }) +}) From f21f81dcfc4ddc0e58b20e653768205685b682ef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:03:11 -0700 Subject: [PATCH 21/43] fix(agents): find OMP by its full project name (#20647) * fix(agents): find OMP by its full project name * test(agents): make picker baseline proof omit OMP aliases * style(test): brace picker baseline condition --- src/renderer/src/lib/agent-catalog.tsx | 2 + .../src/lib/agent-picker-search.test.ts | 9 ++ src/renderer/src/lib/agent-picker-search.ts | 3 +- .../omp-picker-search-rendered/README.md | 17 ++++ .../omp-picker-search-rendered/fixture.css | 4 + .../omp-picker-search-rendered/fixture.tsx | 29 ++++++ .../omp-picker-search-rendered/index.html | 10 ++ .../tools/omp-picker-search-rendered/run.mjs | 93 +++++++++++++++++++ 8 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 tests/tools/omp-picker-search-rendered/README.md create mode 100644 tests/tools/omp-picker-search-rendered/fixture.css create mode 100644 tests/tools/omp-picker-search-rendered/fixture.tsx create mode 100644 tests/tools/omp-picker-search-rendered/index.html create mode 100644 tests/tools/omp-picker-search-rendered/run.mjs diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index b6075183947..cf46fd9aae7 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -21,6 +21,7 @@ export type AgentCatalogEntry = { label: string /** Default CLI binary name used for PATH detection. */ cmd: string + searchAliases?: readonly string[] /** Direct or bundled image URL for agents whose project identity is not represented by a favicon service. */ iconUrl?: string /** Domain for Google's favicon service — used for agents without an SVG icon. */ @@ -123,6 +124,7 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => id: 'omp', label: translate('auto.lib.agent.catalog.09973b4d84', 'OMP'), cmd: 'omp', + searchAliases: ['oh-my-pi', 'oh my pi'], // Why: no faviconDomain — omp renders the hand-authored OmpIcon glyph, so a // favicon fallback would never be reached. homepageUrl: 'https://omp.sh' diff --git a/src/renderer/src/lib/agent-picker-search.test.ts b/src/renderer/src/lib/agent-picker-search.test.ts index fe26fd53dc3..12fe441103f 100644 --- a/src/renderer/src/lib/agent-picker-search.test.ts +++ b/src/renderer/src/lib/agent-picker-search.test.ts @@ -25,6 +25,15 @@ afterEach(() => { }) describe('agent picker search', () => { + it.each(['oh-my-pi', 'oh my pi', 'OH-MY-PI'])('finds OMP by its project name: %s', (query) => { + expect(searchAgentPickerEntries(AGENT_CATALOG, query).map((agent) => agent.id)).toEqual(['omp']) + }) + + it('does not offer unavailable OMP through a search alias', () => { + const available = AGENT_CATALOG.filter((agent) => agent.id !== 'omp') + expect(searchAgentPickerEntries(available, 'oh-my-pi')).toEqual([]) + }) + it('keeps catalog order for an empty query', () => { expect(searchAgentPickerEntries(agents, '').map((agent) => agent.id)).toEqual( agents.map((agent) => agent.id) diff --git a/src/renderer/src/lib/agent-picker-search.ts b/src/renderer/src/lib/agent-picker-search.ts index 6fb980bc8ea..38c368f3d73 100644 --- a/src/renderer/src/lib/agent-picker-search.ts +++ b/src/renderer/src/lib/agent-picker-search.ts @@ -87,7 +87,8 @@ function scoreAgent(agent: AgentCatalogEntry, query: string): number { return Math.min( scoreCandidate(query, agent.label, 0), scoreCandidate(query, agent.id, 600), - scoreCandidate(query, agent.cmd, 650) + scoreCandidate(query, agent.cmd, 650), + ...(agent.searchAliases ?? []).map((alias) => scoreCandidate(query, alias, 650)) ) } diff --git a/tests/tools/omp-picker-search-rendered/README.md b/tests/tools/omp-picker-search-rendered/README.md new file mode 100644 index 00000000000..653a7bd1c33 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/README.md @@ -0,0 +1,17 @@ +# OMP picker project-name search (#14319) + +Run `ORCA_BACKGROUND_LAUNCH=1 node tests/tools/omp-picker-search-rendered/run.mjs`. +Uses production AgentCombobox, the production catalog and canonical CSS in a hidden +Electron renderer. Rebuilds the existing background-launch harness before execution; +all windows must remain invisible and unfocused. No dependency install is needed. + +The probe types `oh-my-pi`, captures the resulting OMP row over CDP, selects it, +asserts the callback receives `omp`, and checks `oh my pi` too. Reports/screenshots +are local under `.bench-fixtures/omp-picker-search-*`. Before proof uses the baseline +catalog without search aliases and `ORCA_OMP_PICKER_BASELINE=1`, expecting no match. + +Available agents are supplied by the fixture. This does not exercise local/SSH/WSL +PATH detection, disabled-agent settings, terminal launch, or a full workspace form. +Search only ranks entries supplied by each caller; aliases cannot introduce an +agent absent from that list. The broader missing-agent explanation in #14319 is +separate from this reproduced project-name search defect. diff --git a/tests/tools/omp-picker-search-rendered/fixture.css b/tests/tools/omp-picker-search-rendered/fixture.css new file mode 100644 index 00000000000..9c08bc0261c --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/fixture.css @@ -0,0 +1,4 @@ +@import '../../../src/renderer/src/assets/main.css'; +@source './fixture.tsx'; +@source '../../../src/renderer/src/components/agent/AgentCombobox.tsx'; +@source '../../../src/renderer/src/components/ui'; diff --git a/tests/tools/omp-picker-search-rendered/fixture.tsx b/tests/tools/omp-picker-search-rendered/fixture.tsx new file mode 100644 index 00000000000..b318cdee827 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/fixture.tsx @@ -0,0 +1,29 @@ +import React, { useState } from 'react' +import { createRoot } from 'react-dom/client' +import AgentCombobox from '../../../src/renderer/src/components/agent/AgentCombobox' +import { getAgentCatalog } from '../../../src/renderer/src/lib/agent-catalog' +import type { TuiAgent } from '../../../src/shared/tui-agent' +import './fixture.css' +const baseline = new URLSearchParams(window.location.search).get('baseline') === '1' +const agents = getAgentCatalog().map((agent) => + baseline && agent.id === 'omp' ? { ...agent, searchAliases: [] } : agent +) +function App() { + const [selected, setSelected] = useState(null) + return ( +
+

Agent picker

+ +

Selected agent: {selected ?? 'none'}

+
+ ) +} +const root = document.getElementById('root') +if (root) { + createRoot(root).render() +} diff --git a/tests/tools/omp-picker-search-rendered/index.html b/tests/tools/omp-picker-search-rendered/index.html new file mode 100644 index 00000000000..44793ff9830 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/tests/tools/omp-picker-search-rendered/run.mjs b/tests/tools/omp-picker-search-rendered/run.mjs new file mode 100644 index 00000000000..14bee4a6752 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/run.mjs @@ -0,0 +1,93 @@ +import { _electron as electron, expect } from '@stablyai/playwright-test' +import { build as buildMain } from 'esbuild' +import { build as buildRenderer } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Requires ORCA_BACKGROUND_LAUNCH=1') +} +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const parent = path.join(root, '.bench-fixtures') +mkdirSync(parent, { recursive: true }) +const output = mkdtempSync(path.join(parent, 'omp-picker-search-')) +const main = path.join(output, 'main.cjs') +await buildMain({ + entryPoints: [path.join(root, 'tests/tools/benchmarks/spinner-rendering/main.ts')], + outfile: main, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'] +}) +await buildRenderer({ + configFile: false, + root: import.meta.dirname, + base: './', + logLevel: 'silent', + plugins: [react(), tailwindcss()], + resolve: { alias: { '@': path.join(root, 'src/renderer/src') } }, + build: { outDir: path.join(output, 'renderer'), emptyOutDir: true } +}) +const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env +const app = await electron.launch({ args: [main], env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' } }) +const report = { + scope: + 'Production AgentCombobox and agent catalog in hidden Electron; supplied available agents, no PATH detection or terminal launch.' +} +try { + const page = await app.firstWindow() + const errors = [] + page.on('pageerror', (error) => { + errors.push(error.message) + console.error(error) + }) + const baseline = process.env.ORCA_OMP_PICKER_BASELINE === '1' + const fixtureUrl = pathToFileURL(path.join(output, 'renderer/index.html')) + if (baseline) { + fixtureUrl.searchParams.set('baseline', '1') + } + await page.goto(fixtureUrl.href) + await page.locator('button[role=combobox]').click() + const search = page.getByPlaceholder('Search agents...') + await search.fill('oh-my-pi') + await expect( + baseline + ? page.getByText('No agents match your search.') + : page.getByRole('option', { name: 'OMP', exact: true }) + ).toBeVisible() + await page.evaluate(async () => { + await Promise.all( + document.getAnimations().map((animation) => animation.finished.catch(() => {})) + ) + }) + const cdp = await page.context().newCDPSession(page) + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }) + writeFileSync( + path.join(output, baseline ? 'before.png' : 'after.png'), + Buffer.from(data, 'base64') + ) + if (!baseline) { + await page.getByRole('option', { name: 'OMP', exact: true }).click() + await expect(page.getByText('Selected agent: omp', { exact: true })).toBeVisible() + await expect(search).toBeHidden() + await page.locator('button[role=combobox]').click() + await search.fill('oh my pi') + await expect(page.getByRole('option', { name: 'OMP', exact: true })).toBeVisible() + } + report.baseline = baseline + expect(errors).toEqual([]) + report.windows = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + ) + expect(report.windows.every((window) => !window.visible && !window.focused)).toBe(true) +} finally { + writeFileSync(path.join(output, 'report.json'), `${JSON.stringify(report, null, 2)}\n`) + console.log(`OMP picker search evidence: ${output}`) + await app.close() +} From 68f0b2e8355d3376e4ace32b1ae0cb81f068bdab Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:08:31 +0200 Subject: [PATCH 22/43] feat(runtime): stream file uploads instead of buffering whole files (#16106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(runtime): stream file uploads instead of buffering whole files Staging read each dropped file whole with readFile(), base64-encoded it (a 4/3 expansion), and passed the string through IPC to the renderer, which re-chunked it. Peak memory was ~2.3x the file size before a byte moved, so a 25 MB per-file cap existed to protect the heap. Staging now records identity only. The byte pump moves into main, where the file handle and the runtime socket both live: 384 KiB slices (512 KiB once base64-encoded, matching the chunk size the renderer used) appended through the existing files.writeBase64Chunk RPC. Peak memory is one slice regardless of file size, so the ceilings become user-safety limits on an unattended transfer — 2 GB per file, 8 GB per drop — and over-limit errors name both the size and the limit. Because staging and streaming are separate calls, the staged entry carries size, inode, device and mtime, and the streamer re-checks all four against the pre-open lstat and against the handle it actually reads. A source replaced or rewritten at the same size between the two calls is refused rather than uploaded under the original name. The post-read check compares mtime as well as size, so an in-place rewrite mid-transfer aborts before commitUpload renames anything into place. O_NOFOLLOW, realpath containment and stat identity are preserved, and the pairing revision plus the runtime id ride every chunk, so a re-pair or a replacement runtime aborts instead of appending the rest of the file to a different host. No wire change: files.writeBase64Chunk and its params are untouched, so old and new hosts behave identically. The SSH import path is separate and unchanged. The web client has no local filesystem to stream from and says so instead of failing obscurely. * fix(runtime): close the empty-upload and per-drop budget holes Two gaps the first pass left open. A zero-byte source returned before the post-transfer identity check, so a file that gained content during the empty write's round trip committed as an empty file at the user's chosen name. The empty chunk now falls through to the same final check the slice loop uses. Each staged source also started its own byte counter, so the 8 GB ceiling capped one source rather than the drop: five 2 GB files staged cleanly at 10 GB total. The IPC handler now carries one budget across sourcePaths and adds only what each source actually staged. The per-file ceiling is still re-enforced where the bytes move; the drop total holds at staging because identity enforcement means each file streams exactly the bytes measured. * docs(runtime): name the invariants the upload helpers carry * fix(runtime): name the source in errors and stop uploads with their window Three problems an independent review turned up. A dropped file's relative path is '', so the over-limit error read "'' is 3 GB, over the 2 GB per-file remote import limit" — the message this change exists to fix, naming nothing. Errors now fall back to the file's own name; the staged entry keeps '' so the destination path is unaffected. The streamer had the same shape, falling back to the hidden .orca-upload- temp destination, a path the user never chose. The byte loop used to live in the renderer and died with it. Moving it into main meant closing or reloading the window left the rest of a multi-GB transfer running, with the renderer's temp cleanup never reaching its finally. An AbortSignal now rides the caller's lifetime and every chunk, is re-checked per slice, and main sweeps the abandoned temp path itself when the renderer is no longer there to do it. Upload failures also reached the import result wrapped in Electron's "Error invoking remote method '...'" prefix, because the throw crossed IPC instead of happening in-renderer; extractIpcErrorMessage unwraps it. An existing staging test asserted the empty-name message, so it encoded the bug rather than catching it; it now asserts the file name. * test(runtime): cover the containment check and the per-chunk host guards The "escapes the dropped root" test only reached the lstat symlink guard, so assertEntryInsideRoot had no coverage at all. The shape that actually needs it is a regular file under a symlinked intermediate directory: lstat sees a plain file, and realpath containment is the only thing that refuses it. Disabling the guard now fails this test and nothing else. Nothing asserted that the SSH target, connection generation and execution host reach the writeBase64Chunk params either — the renderer tests stop at the IPC boundary, so the streamer's half of that contract was untested. * fix(runtime): survive a straggling append when sweeping an aborted upload Aborting rejects the in-flight chunk locally, but the host may still apply that append, and appends open with flag 'a' — which recreates the file the sweep just deleted. The delete and the straggler also race: they are separate calls on a queue that is not ordered between them. Slices are strictly sequential, so at most one append can be outstanding. A second pass after it has had time to land is therefore sufficient, not merely a heuristic. The sweep moves out of filesystem-mutations.ts into its own module so the behaviour is testable directly. Found by an independent review pass, which also pointed out that the "escapes the dropped root" test only reached the lstat symlink guard. * fix(runtime): abort uploads only when the document commits, and honour manual disconnect per chunk did-start-navigation fires before will-navigate blocks an external link or a stray file drop, and the renderer survives those (verified against Electron 43 with a hidden window). Aborting there killed a healthy upload with a misleading 'window went away' error. did-navigate fires only once a new document has replaced the caller. The renderer's per-chunk calls used to go through the IPC handler that refuses a manually disconnected environment; the loop in main made no such check, so a disconnect mid-upload kept pushing the rest of the file. The handler now resolves the selector to an environment id and the streamer checks it per slice. Adds slice-boundary coverage against the real chunk schema and host write flags, staging-to-stream on a real filesystem, and handler-level lifetime tests. --------- Co-authored-by: Neil --- .../ipc/filesystem-import-result-types.ts | 31 +- src/main/ipc/filesystem-import.test.ts | 53 +- ...ilesystem-mutations-runtime-upload.test.ts | 176 ++++++ src/main/ipc/filesystem-mutations.ts | 55 +- .../filesystem-runtime-upload-staging.test.ts | 166 +++++ .../ipc/filesystem-runtime-upload-staging.ts | 97 +-- src/main/ipc/renderer-lifetime-abort.test.ts | 91 +++ src/main/ipc/renderer-lifetime-abort.ts | 45 ++ ...ntime-environment-connectivity-handlers.ts | 5 +- .../runtime-environment-manual-disconnect.ts | 2 + src/main/ipc/runtime-import-limits.test.ts | 33 + src/main/ipc/runtime-import-limits.ts | 18 + .../ipc/runtime-upload-file-stream.test.ts | 438 +++++++++++++ src/main/ipc/runtime-upload-file-stream.ts | 213 +++++++ .../runtime-upload-slice-boundaries.test.ts | 383 ++++++++++++ .../ipc/runtime-upload-temp-sweep.test.ts | 94 +++ src/main/ipc/runtime-upload-temp-sweep.ts | 49 ++ src/preload/api/filesystem-api.ts | 34 +- src/preload/api/fs-bridge.ts | 33 +- ...untime-file-client-external-import.test.ts | 584 ++++-------------- .../runtime-file-client-test-harness.ts | 6 +- .../src/runtime/runtime-file-import-client.ts | 32 +- ...ntime-file-import-pairing-revision.test.ts | 97 +-- .../src/runtime/runtime-file-upload-client.ts | 89 +-- .../src/web/preload-api/web-filesystem-api.ts | 5 + src/shared/runtime-upload-staging-contract.ts | 50 ++ 26 files changed, 2190 insertions(+), 689 deletions(-) create mode 100644 src/main/ipc/filesystem-mutations-runtime-upload.test.ts create mode 100644 src/main/ipc/filesystem-runtime-upload-staging.test.ts create mode 100644 src/main/ipc/renderer-lifetime-abort.test.ts create mode 100644 src/main/ipc/renderer-lifetime-abort.ts create mode 100644 src/main/ipc/runtime-import-limits.test.ts create mode 100644 src/main/ipc/runtime-import-limits.ts create mode 100644 src/main/ipc/runtime-upload-file-stream.test.ts create mode 100644 src/main/ipc/runtime-upload-file-stream.ts create mode 100644 src/main/ipc/runtime-upload-slice-boundaries.test.ts create mode 100644 src/main/ipc/runtime-upload-temp-sweep.test.ts create mode 100644 src/main/ipc/runtime-upload-temp-sweep.ts create mode 100644 src/shared/runtime-upload-staging-contract.ts diff --git a/src/main/ipc/filesystem-import-result-types.ts b/src/main/ipc/filesystem-import-result-types.ts index d1d9f446836..1d3e1a3fcad 100644 --- a/src/main/ipc/filesystem-import-result-types.ts +++ b/src/main/ipc/filesystem-import-result-types.ts @@ -1,3 +1,8 @@ +import type { + StagedRuntimeUploadEntry, + StagedRuntimeUploadSource +} from '../../shared/runtime-upload-staging-contract' + export type ImportSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' export type ResolveDroppedPathsResult = { @@ -27,25 +32,7 @@ export type ImportItemResult = reason: string } -export type StagedExternalImportSource = - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: StagedExternalImportEntry[] - } - | { - sourcePath: string - status: 'skipped' - reason: ImportSkipReason - } - | { - sourcePath: string - status: 'failed' - reason: string - } - -export type StagedExternalImportEntry = - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } +// Why: staging crosses IPC to the renderer and back into the streamer, so the +// shape lives in shared and every layer names the same type. +export type StagedExternalImportSource = StagedRuntimeUploadSource +export type StagedExternalImportEntry = StagedRuntimeUploadEntry diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index bb143987c96..7703cd8a8b1 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -73,6 +73,7 @@ describe('fs:importExternalPaths', () => { size: 12, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -94,6 +95,7 @@ describe('fs:importExternalPaths', () => { size: entry.isDir ? 0 : 12, ino: entry.isDir ? 2 : 3, dev: 1, + mtimeMs: 1700000000000, isFile: () => !entry.isDir, isDirectory: () => entry.isDir, isSymbolicLink: () => false @@ -142,6 +144,7 @@ describe('fs:importExternalPaths', () => { size: content.byteLength, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), createReadStream: () => Readable.from([content]), @@ -216,6 +219,7 @@ describe('fs:importExternalPaths', () => { size: 12, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), createReadStream: () => Readable.from([Buffer.from('file-content')]), @@ -484,6 +488,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -498,6 +503,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: readFileHandleMock, @@ -514,11 +520,21 @@ describe('fs:importExternalPaths', () => { status: 'staged', name: 'logo.png', kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }] + entries: [ + { + relativePath: '', + kind: 'file', + byteLength: 4, + inode: 1, + deviceId: 1, + modifiedAtMs: 1700000000000 + } + ] } ]) expect(copyFileMock).not.toHaveBeenCalled() - expect(readFileHandleMock).toHaveBeenCalled() + // Why: bodies stream at upload time, so staging must never read the file. + expect(readFileHandleMock).not.toHaveBeenCalled() expect(closeMock).toHaveBeenCalled() }) @@ -533,6 +549,7 @@ describe('fs:importExternalPaths', () => { size: 0, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false @@ -543,6 +560,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -578,6 +596,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: vi.fn().mockResolvedValue(Buffer.from('icon')), @@ -597,7 +616,14 @@ describe('fs:importExternalPaths', () => { entries: [ { relativePath: '', kind: 'directory' }, { relativePath: '..assets', kind: 'directory' }, - { relativePath: '..assets/icon.txt', kind: 'file', contentBase64: 'aWNvbg==' } + { + relativePath: '..assets/icon.txt', + kind: 'file', + byteLength: 4, + inode: 2, + deviceId: 1, + modifiedAtMs: 1700000000000 + } ] } ]) @@ -612,6 +638,7 @@ describe('fs:importExternalPaths', () => { size: 0, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false @@ -637,14 +664,15 @@ describe('fs:importExternalPaths', () => { expect(openMock).not.toHaveBeenCalled() }) - it('checks runtime upload directory byte budget before reading a file that exceeds the total cap', async () => { + it('checks runtime upload directory byte budget before opening a file that exceeds the total cap', async () => { const sourcePath = '/tmp/dropped/project' const resolvedPath = path.resolve(sourcePath) const filePaths = ['one.bin', 'two.bin', 'three.bin', 'four.bin', 'overflow.bin'].map((name) => path.join(resolvedPath, name) ) const mib = 1024 * 1024 - const regularSize = 25 * mib + // Four files exactly fill the 8 GB total ceiling; the fifth pushes past it. + const regularSize = 2 * 1024 * mib const overflowSize = Number(mib) const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk')) @@ -654,6 +682,7 @@ describe('fs:importExternalPaths', () => { size: 0, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false @@ -666,6 +695,7 @@ describe('fs:importExternalPaths', () => { size, ino: fileIndex + 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -689,6 +719,7 @@ describe('fs:importExternalPaths', () => { size: regularSize, ino: fileIndex + 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: readFileMock, @@ -702,11 +733,9 @@ describe('fs:importExternalPaths', () => { sourcePaths: [sourcePath] })) as { sources: { status: string; reason?: string }[] } - expect(result.sources[0]).toMatchObject({ - status: 'failed', - reason: 'Remote import is too large' - }) - expect(readFileMock).toHaveBeenCalledTimes(4) + expect(result.sources[0]).toMatchObject({ status: 'failed' }) + expect(result.sources[0]?.reason).toContain('total remote import limit') + expect(readFileMock).not.toHaveBeenCalled() expect(openMock).not.toHaveBeenCalledWith(filePaths.at(-1), expect.anything()) }) @@ -719,6 +748,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -732,6 +762,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: readFileHandleMock, @@ -744,7 +775,7 @@ describe('fs:importExternalPaths', () => { expect(result.sources[0]).toMatchObject({ status: 'failed', - reason: "File changed during upload staging: ''" + reason: "File changed during upload staging: 'logo.png'" }) expect(readFileHandleMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/filesystem-mutations-runtime-upload.test.ts b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts new file mode 100644 index 00000000000..fa7edc1d5b7 --- /dev/null +++ b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts @@ -0,0 +1,176 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const handlers = new Map Promise>() +const { handleMock, streamMock, sweepMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + streamMock: vi.fn(), + sweepMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock }, + app: { getPath: () => '/user-data' } +})) +vi.mock('./runtime-upload-file-stream', () => ({ + streamExternalFileToRuntime: streamMock +})) +vi.mock('./runtime-upload-temp-sweep', () => ({ + sweepAbandonedRuntimeUploadTempPath: sweepMock +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: (_userDataPath: string, selector: string) => ({ + id: selector === 'env-alias' ? 'env-1' : selector + }) +})) + +import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { RENDERER_GONE_MESSAGE } from './renderer-lifetime-abort' + +const request = { + environmentId: 'env-1', + sourceRootPath: '/drop/file.bin', + entryRelativePath: '', + expected: { byteLength: 1, inode: 1, deviceId: 1, modifiedAtMs: 1 }, + worktree: 'wt-1', + relativePath: '.file.bin.orca-upload-x', + expectedEnvironmentPairingRevision: 3, + expectedEnvironmentRuntimeId: 'rt-1' +} + +function fakeSender(): EventEmitter { + return new EventEmitter() +} + +function listenerCount(sender: EventEmitter): number { + return ['destroyed', 'render-process-gone', 'did-navigate'].reduce( + (total, name) => total + sender.listenerCount(name), + 0 + ) +} + +beforeEach(() => { + handlers.clear() + handleMock.mockReset() + streamMock.mockReset() + sweepMock.mockReset() + sweepMock.mockResolvedValue(undefined) + handleMock.mockImplementation((channel: string, handler: never) => { + handlers.set(channel, handler) + }) + registerFilesystemMutationHandlers( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the upload handler under test never reads the store; registration only needs a Store-shaped value. + { getRepos: () => [], getSettings: () => ({ workspaceDir: '/workspace' }) } as never + ) +}) + +function invoke(sender: EventEmitter): Promise { + return handlers.get('fs:uploadExternalFileToRuntime')!({ sender }, request) +} + +describe('fs:uploadExternalFileToRuntime', () => { + it('streams with the user data path and a live signal, and leaves no listeners behind', async () => { + const sender = fakeSender() + streamMock.mockImplementation(async (args: { userDataPath: string; signal: AbortSignal }) => { + expect(args.userDataPath).toBe('/user-data') + expect(args.signal.aborted).toBe(false) + expect(listenerCount(sender)).toBe(3) + return { byteLength: 42 } + }) + + await expect(invoke(sender)).resolves.toEqual({ byteLength: 42 }) + + expect(streamMock).toHaveBeenCalledWith(expect.objectContaining(request)) + expect(sweepMock).not.toHaveBeenCalled() + expect(listenerCount(sender)).toBe(0) + }) + + it('resolves the selector to the environment id before streaming and sweeping', async () => { + const sender = fakeSender() + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('destroyed') + }) + ) + + await expect( + handlers.get('fs:uploadExternalFileToRuntime')!( + { sender }, + { ...request, environmentId: 'env-alias' } + ) + ).rejects.toThrow(RENDERER_GONE_MESSAGE) + + expect(streamMock).toHaveBeenCalledWith(expect.objectContaining({ environmentId: 'env-1' })) + expect(sweepMock).toHaveBeenCalledWith('/user-data', { ...request, environmentId: 'env-1' }) + }) + + it('aborts, sweeps the temp path, and rethrows when the renderer is destroyed mid-stream', async () => { + const sender = fakeSender() + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('destroyed') + }) + ) + + await expect(invoke(sender)).rejects.toThrow(RENDERER_GONE_MESSAGE) + + expect(sweepMock).toHaveBeenCalledTimes(1) + expect(sweepMock).toHaveBeenCalledWith('/user-data', request) + expect(listenerCount(sender)).toBe(0) + }) + + it('aborts once a reload commits, not on a blocked navigation or an in-app route change', async () => { + const sender = fakeSender() + let observed: AbortSignal | undefined + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve, reject) => { + observed = signal + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('did-start-navigation', { isMainFrame: true, isSameDocument: true }) + sender.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + sender.emit('will-navigate', { defaultPrevented: true }, 'https://example.invalid/') + queueMicrotask(() => { + expect(signal.aborted).toBe(false) + sender.emit('did-navigate', 'file:///app/index.html', 200, 'OK') + resolve({ byteLength: 0 }) + }) + }) + ) + + await expect(invoke(sender)).rejects.toThrow(RENDERER_GONE_MESSAGE) + expect(observed?.aborted).toBe(true) + expect(sweepMock).toHaveBeenCalledTimes(1) + }) + + it('does not sweep when the stream fails while the renderer is still alive', async () => { + const sender = fakeSender() + streamMock.mockRejectedValue(new Error("File changed since it was staged: 'file.bin'")) + + await expect(invoke(sender)).rejects.toThrow("File changed since it was staged: 'file.bin'") + + expect(sweepMock).not.toHaveBeenCalled() + expect(listenerCount(sender)).toBe(0) + }) + + it('still rethrows the stream error if the sweep itself throws', async () => { + const sender = fakeSender() + sweepMock.mockRejectedValue(new Error('sweep exploded')) + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('render-process-gone') + }) + ) + + // Why: the sweep contract is "never rejects"; if it ever did, this documents + // that the handler would surface the sweep error instead of the upload's. + await expect(invoke(sender)).rejects.toThrow('sweep exploded') + expect(listenerCount(sender)).toBe(0) + }) +}) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 57ac0c5e197..7cce83aa3e5 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { app, ipcMain } from 'electron' import { constants } from 'node:fs' import { copyFile, mkdir, writeFile } from 'node:fs/promises' import { basename, dirname } from 'node:path' @@ -18,7 +18,15 @@ import type { StagedExternalImportSource } from './filesystem-import-result-types' import { importOneSource } from './filesystem-import-local' -import { stageOneSourceForRuntimeUpload } from './filesystem-runtime-upload-staging' +import { + stagedRuntimeUploadByteLength, + stageOneSourceForRuntimeUpload +} from './filesystem-runtime-upload-staging' +import { streamExternalFileToRuntime } from './runtime-upload-file-stream' +import { abortWhenRendererGone } from './renderer-lifetime-abort' +import { sweepAbandonedRuntimeUploadTempPath } from './runtime-upload-temp-sweep' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import { resolveEnvironment } from '../../shared/runtime-environment-store' /** * IPC handlers for file/folder creation and renaming. @@ -196,13 +204,54 @@ export function registerFilesystemMutationHandlers(store: Store): void { args: { sourcePaths: string[] } ): Promise<{ sources: StagedExternalImportSource[] }> => { const sources: StagedExternalImportSource[] = [] + // Why: one budget for the whole drop — per-source counters would let five + // 2 GB files through a ceiling meant to cap the drop. + let totalBytes = 0 for (const sourcePath of args.sourcePaths) { - sources.push(await stageOneSourceForRuntimeUpload(sourcePath)) + const source = await stageOneSourceForRuntimeUpload(sourcePath, totalBytes) + totalBytes += stagedRuntimeUploadByteLength(source) + sources.push(source) } return { sources } } ) + // Why: the file handle and the runtime socket both live in main, so the byte + // pump runs here. The renderer keeps deconflict/commit/rollback orchestration + // and never sees file contents. + ipcMain.handle( + 'fs:uploadExternalFileToRuntime', + async (event, args: RuntimeUploadFileStreamRequest): Promise<{ byteLength: number }> => { + const userDataPath = app.getPath('userData') + // Why: the streamer's manual-disconnect check keys on the environment id, + // and the renderer may pass any selector the store resolves. + const request = { + ...args, + environmentId: resolveEnvironment(userDataPath, args.environmentId).id + } + // Why: the renderer's own loop died with its window. Now that the bytes + // move in main, a reload or close has to stop the transfer explicitly, + // or a multi-GB upload outlives the window that asked for it. + const lifetime = abortWhenRendererGone(event.sender) + try { + return await streamExternalFileToRuntime({ + ...request, + userDataPath, + signal: lifetime.signal + }) + } catch (error) { + if (lifetime.signal.aborted) { + // Why: the renderer owns temp cleanup, and it is gone — so the + // abandoned temp path is only collectable from here. + await sweepAbandonedRuntimeUploadTempPath(userDataPath, request) + } + throw error + } finally { + lifetime.dispose() + } + } + ) + // Why: terminal drag-and-drop resolver. Local worktrees pass paths through // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees // upload each path into `${worktreePath}/.orca/drops/` and return remote diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts new file mode 100644 index 00000000000..3fe70b4e1f2 --- /dev/null +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -0,0 +1,166 @@ +import { lstat, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeImportLimits from './runtime-import-limits' + +type RuntimeImportLimitsModule = typeof RuntimeImportLimits + +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) +// Why: real ceilings are gigabytes, and truncate() is not sparse on NTFS, so a +// literal over-limit fixture would allocate that much on Windows CI. +vi.mock('./runtime-import-limits', async (importOriginal) => ({ + ...(await importOriginal()), + REMOTE_IMPORT_MAX_FILE_BYTES: 4 * 1024, + REMOTE_IMPORT_MAX_TOTAL_BYTES: 16 * 1024 +})) + +const { stagedRuntimeUploadByteLength, stageOneSourceForRuntimeUpload } = + await import('./filesystem-runtime-upload-staging') + +let workDir: string + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-staging-')) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +describe('stageOneSourceForRuntimeUpload', () => { + it('records size instead of file contents so staging never holds the body', async () => { + const filePath = join(workDir, 'note.txt') + await writeFile(filePath, 'hello world') + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ + status: 'staged', + kind: 'file', + name: 'note.txt', + entries: [{ relativePath: '', kind: 'file', byteLength: 11 }] + }) + expect(JSON.stringify(staged)).not.toContain('contentBase64') + }) + + it('records the identity the uploader re-checks, not just the size', async () => { + const filePath = join(workDir, 'note.txt') + await writeFile(filePath, 'hello world') + const stat = await lstat(filePath) + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ + status: 'staged', + entries: [ + { + byteLength: 11, + inode: stat.ino, + deviceId: stat.dev, + modifiedAtMs: stat.mtimeMs + } + ] + }) + }) + + it('stages a file with no cap error, where the old buffering path refused', async () => { + const filePath = join(workDir, 'big.bin') + await writeFile(filePath, Buffer.alloc(3 * 1024)) + + await expect(stageOneSourceForRuntimeUpload(filePath)).resolves.toMatchObject({ + status: 'staged', + entries: [{ kind: 'file', byteLength: 3 * 1024 }] + }) + }) + + it('names the file, the actual size and the limit when a file is over the ceiling', async () => { + const filePath = join(workDir, 'clip.mp4') + await writeFile(filePath, Buffer.alloc(6 * 1024)) + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ status: 'failed' }) + // Why: a dropped file's relative path is '', so this is the regression that + // would otherwise report "'' is 6 KB, over the 4 KB ... limit". + expect(staged.status === 'failed' && staged.reason).toBe( + "'clip.mp4' is 6 KB, over the 4 KB per-file remote import limit" + ) + }) + + it('names the offending entry by its path inside a dropped directory', async () => { + const rootPath = join(workDir, 'media') + await mkdir(join(rootPath, 'clips'), { recursive: true }) + await writeFile(join(rootPath, 'clips', 'big.mp4'), Buffer.alloc(6 * 1024)) + + const staged = await stageOneSourceForRuntimeUpload(rootPath) + + expect(staged.status === 'failed' && staged.reason).toContain("'clips/big.mp4'") + }) + + it('counts earlier sources in the drop against the total ceiling', async () => { + const filePath = join(workDir, 'second.bin') + await writeFile(filePath, Buffer.alloc(3 * 1024)) + + // Alone it fits; after 14 KB of earlier sources the 16 KB drop ceiling is gone. + await expect(stageOneSourceForRuntimeUpload(filePath, 0)).resolves.toMatchObject({ + status: 'staged' + }) + const overBudget = await stageOneSourceForRuntimeUpload(filePath, 14 * 1024) + expect(overBudget).toMatchObject({ status: 'failed' }) + expect(overBudget.status === 'failed' && overBudget.reason).toContain( + 'total remote import limit' + ) + }) + + it('reports the bytes a source contributes to the drop budget', async () => { + const rootPath = join(workDir, 'tree') + await mkdir(join(rootPath, 'nested'), { recursive: true }) + await writeFile(join(rootPath, 'a.txt'), 'aa') + await writeFile(join(rootPath, 'nested', 'b.txt'), 'bbb') + + const staged = await stageOneSourceForRuntimeUpload(rootPath) + + expect(stagedRuntimeUploadByteLength(staged)).toBe(5) + expect( + stagedRuntimeUploadByteLength({ + sourcePath: '/missing', + status: 'skipped', + reason: 'missing' + }) + ).toBe(0) + }) + + // symlink() needs privileges or Developer Mode on Windows. + it.skipIf(process.platform === 'win32')('keeps rejecting symlinked sources', async () => { + const targetPath = join(workDir, 'target.txt') + await writeFile(targetPath, 'data') + const linkPath = join(workDir, 'link.txt') + await symlink(targetPath, linkPath) + + await expect(stageOneSourceForRuntimeUpload(linkPath)).resolves.toMatchObject({ + status: 'skipped', + reason: 'symlink' + }) + }) + + it('stages directory trees as metadata for every entry', async () => { + const rootPath = join(workDir, 'assets') + await mkdir(join(rootPath, 'nested'), { recursive: true }) + await writeFile(join(rootPath, 'a.txt'), 'aa') + await writeFile(join(rootPath, 'nested', 'b.txt'), 'bbb') + + const staged = await stageOneSourceForRuntimeUpload(rootPath) + + expect(staged.status).toBe('staged') + const entries = staged.status === 'staged' ? staged.entries : [] + expect(entries).toEqual( + expect.arrayContaining([ + { relativePath: '', kind: 'directory' }, + expect.objectContaining({ relativePath: 'a.txt', kind: 'file', byteLength: 2 }), + { relativePath: 'nested', kind: 'directory' }, + expect.objectContaining({ relativePath: 'nested/b.txt', kind: 'file', byteLength: 3 }) + ]) + ) + }) +}) diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index 5af76029b98..86b5f884820 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -1,3 +1,8 @@ +import { + formatByteCeiling, + REMOTE_IMPORT_MAX_FILE_BYTES, + REMOTE_IMPORT_MAX_TOTAL_BYTES +} from './runtime-import-limits' import { constants } from 'node:fs' import { lstat, open, readdir, realpath } from 'node:fs/promises' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' @@ -8,18 +13,31 @@ import type { StagedExternalImportSource } from './filesystem-import-result-types' -const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 -const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 - class RuntimeUploadSymlinkError extends Error {} +/** Bytes this source contributes to the drop budget; 0 unless it staged. */ +export function stagedRuntimeUploadByteLength(source: StagedExternalImportSource): number { + if (source.status !== 'staged') { + return 0 + } + return source.entries.reduce( + (total, entry) => (entry.kind === 'file' ? total + entry.byteLength : total), + 0 + ) +} + +/** + * @param totalBytesBefore Bytes already staged by earlier sources in the same drop, + * so the total ceiling covers the whole drop rather than each source alone. + */ export async function stageOneSourceForRuntimeUpload( - sourcePath: string + sourcePath: string, + totalBytesBefore = 0 ): Promise { const resolvedSource = resolve(sourcePath) // Why: runtime uploads read client-local paths in the client main process; - // authorize before lstat/readFile just like local copy imports. + // authorize before lstat just like local copy imports. authorizeExternalPath(resolvedSource) let sourceStat: Awaited> @@ -52,8 +70,8 @@ export async function stageOneSourceForRuntimeUpload( } try { const entries = sourceStat.isDirectory() - ? await stageDirectoryEntries(resolvedSource) - : [(await stageFileEntry(resolvedSource, '')).entry] + ? await stageDirectoryEntries(resolvedSource, totalBytesBefore) + : [(await stageFileEntry(resolvedSource, '', { totalBytesBefore })).entry] return { sourcePath, status: 'staged', @@ -73,9 +91,12 @@ export async function stageOneSourceForRuntimeUpload( } } -async function stageDirectoryEntries(rootPath: string): Promise { +async function stageDirectoryEntries( + rootPath: string, + totalBytesBefore: number +): Promise { const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }] - let totalBytes = 0 + let totalBytes = totalBytesBefore const rootRealPath = await realpath(rootPath) async function visit(dirPath: string): Promise { @@ -126,52 +147,52 @@ async function stageDirectoryEntries(rootPath: string): Promise { const statResult = await lstat(filePath) const displayPath = normalizeRelativeUploadPath(relativePath) + // Why: a dropped file's relative path is '', so errors would name nothing. + // The entry keeps '' — only the message falls back to the file's own name. + const displayName = displayPath || basename(filePath) if (statResult.isSymbolicLink()) { - throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${displayPath}'`) + throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${displayName}'`) } if (!statResult.isFile()) { - throw new Error(`Unsupported file type in '${displayPath}'`) + throw new Error(`Unsupported file type in '${displayName}'`) } - if (options?.rootRealPath) { - await assertRealPathInsideRoot(options.rootRealPath, filePath, displayPath) + if (options.rootRealPath) { + await assertRealPathInsideRoot(options.rootRealPath, filePath, displayName) } - const initialTotalBytes = - options?.totalBytesBefore === undefined - ? statResult.size - : options.totalBytesBefore + statResult.size - assertRemoteUploadBudget(relativePath, statResult.size, initialTotalBytes) + assertRemoteUploadBudget(displayName, statResult.size, options.totalBytesBefore + statResult.size) const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) try { const openedStat = await fileHandle.stat() if (!openedStat.isFile()) { - throw new Error(`Unsupported file type in '${displayPath}'`) + throw new Error(`Unsupported file type in '${displayName}'`) } if ( openedStat.size !== statResult.size || (statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) || (statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev) ) { - throw new Error(`File changed during upload staging: '${displayPath}'`) - } - const totalBytes = - options?.totalBytesBefore === undefined - ? openedStat.size - : options.totalBytesBefore + openedStat.size - assertRemoteUploadBudget(relativePath, openedStat.size, totalBytes) - const buffer = await fileHandle.readFile() - const afterReadStat = await fileHandle.stat() - if (afterReadStat.size !== openedStat.size) { - throw new Error(`File changed during upload staging: '${displayPath}'`) + throw new Error(`File changed during upload staging: '${displayName}'`) } + assertRemoteUploadBudget( + displayName, + openedStat.size, + options.totalBytesBefore + openedStat.size + ) + // Why: bytes are read slice-by-slice at upload time, so staging records the + // identity the streamer re-checks rather than the body itself. Size alone + // would let a same-size replacement slip through between the two calls. return { entry: { relativePath: displayPath, kind: 'file', - contentBase64: buffer.toString('base64') + byteLength: openedStat.size, + inode: openedStat.ino, + deviceId: openedStat.dev, + modifiedAtMs: openedStat.mtimeMs }, byteLength: openedStat.size } @@ -197,15 +218,21 @@ async function assertRealPathInsideRoot( } function assertRemoteUploadBudget( - relativePath: string, + displayName: string, fileBytes: number, totalBytes: number ): void { if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { - throw new Error(`'${relativePath}' is too large for remote import`) + throw new Error( + `'${displayName}' is ${formatByteCeiling(fileBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + ) } if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) { - throw new Error('Remote import is too large') + throw new Error( + `This import is ${formatByteCeiling(totalBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_TOTAL_BYTES)} total remote import limit` + ) } } diff --git a/src/main/ipc/renderer-lifetime-abort.test.ts b/src/main/ipc/renderer-lifetime-abort.test.ts new file mode 100644 index 00000000000..9e5b0f9a7d9 --- /dev/null +++ b/src/main/ipc/renderer-lifetime-abort.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it } from 'vitest' +import { + abortWhenRendererGone, + RENDERER_GONE_MESSAGE, + type RendererLifetimeSender +} from './renderer-lifetime-abort' + +function fakeSender(): RendererLifetimeSender & EventEmitter { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: EventEmitter implements the once/on/removeListener surface this helper uses, and those three are all it calls; WebContents' overloaded signatures cannot be satisfied structurally. + return new EventEmitter() as RendererLifetimeSender & EventEmitter +} + +describe('abortWhenRendererGone', () => { + it('aborts when the renderer is destroyed', () => { + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + expect(signal.aborted).toBe(false) + sender.emit('destroyed') + + expect(signal.aborted).toBe(true) + expect(String(signal.reason)).toContain(RENDERER_GONE_MESSAGE) + }) + + it('aborts when the render process is gone', () => { + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + sender.emit('render-process-gone') + + expect(signal.aborted).toBe(true) + }) + + it('aborts once a reload has replaced the document, not on in-app route changes', () => { + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: true, + url: 'file:///app#x' + }) + sender.emit('did-navigate-in-page', 'file:///app#x') + expect(signal.aborted).toBe(false) + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: 'file:///app' + }) + sender.emit('did-navigate', 'file:///app', 200, 'OK') + expect(signal.aborted).toBe(true) + }) + + it('ignores a main-frame navigation that starts but is blocked before it commits', () => { + // Why: Electron emits did-start-navigation before will-navigate gets to + // preventDefault() an external link or a stray file drop; the renderer + // document survives those, so the upload must too. + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: 'https://example.invalid/' + }) + sender.emit('will-navigate', { defaultPrevented: true }, 'https://example.invalid/') + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: 'file:///Users/me/dropped.png' + }) + sender.emit('will-navigate', { defaultPrevented: true }, 'file:///Users/me/dropped.png') + + expect(signal.aborted).toBe(false) + }) + + it('leaves no listeners on a long-lived renderer once disposed', () => { + const sender = fakeSender() + const { dispose } = abortWhenRendererGone(sender) + + expect(sender.listenerCount('destroyed')).toBe(1) + dispose() + dispose() + + expect(sender.listenerCount('destroyed')).toBe(0) + expect(sender.listenerCount('render-process-gone')).toBe(0) + expect(sender.listenerCount('did-navigate')).toBe(0) + }) +}) diff --git a/src/main/ipc/renderer-lifetime-abort.ts b/src/main/ipc/renderer-lifetime-abort.ts new file mode 100644 index 00000000000..213abab8f47 --- /dev/null +++ b/src/main/ipc/renderer-lifetime-abort.ts @@ -0,0 +1,45 @@ +import type { WebContents } from 'electron' + +export type RendererLifetimeSender = Pick + +export const RENDERER_GONE_MESSAGE = 'The window that started this upload went away' + +/** + * Abort signal that fires when the calling renderer goes away. + * + * Work the renderer used to do itself died with it. Once it moves into main, + * nothing stops a long transfer from outliving the window that asked for it, + * so the caller's lifetime has to be wired up explicitly. + * + * Always `dispose()` in a finally — otherwise every call leaks a listener on a + * long-lived WebContents. + */ +export function abortWhenRendererGone(sender: RendererLifetimeSender): { + signal: AbortSignal + dispose: () => void +} { + const controller = new AbortController() + const abort = (): void => controller.abort(new Error(RENDERER_GONE_MESSAGE)) + let disposed = false + + sender.once('destroyed', abort) + sender.once('render-process-gone', abort) + // Why: did-start-navigation also fires for navigations that will-navigate then + // blocks — an external link, a stray file drop — and the renderer survives + // those. did-navigate fires only once a new document has replaced the caller, + // and never for same-document route changes inside the live app. + sender.once('did-navigate', abort) + + return { + signal: controller.signal, + dispose: () => { + if (disposed) { + return + } + disposed = true + sender.removeListener('destroyed', abort) + sender.removeListener('render-process-gone', abort) + sender.removeListener('did-navigate', abort) + } + } +} diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index d461b1d080a..84d2754d556 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -27,7 +27,8 @@ import { import { clearRuntimeEnvironmentManualDisconnect, isRuntimeEnvironmentManuallyDisconnected, - markRuntimeEnvironmentManuallyDisconnected + markRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE } from './runtime-environment-manual-disconnect' import { callRuntimeEnvironment, @@ -42,7 +43,7 @@ function manuallyDisconnectedResponse( ok: false, error: { code: 'runtime_manually_disconnected', - message: 'Runtime environment is manually disconnected.' + message: RUNTIME_MANUALLY_DISCONNECTED_MESSAGE }, _meta: { runtimeId: environment.runtimeId } } diff --git a/src/main/ipc/runtime-environment-manual-disconnect.ts b/src/main/ipc/runtime-environment-manual-disconnect.ts index f9f94e7f438..31c300895df 100644 --- a/src/main/ipc/runtime-environment-manual-disconnect.ts +++ b/src/main/ipc/runtime-environment-manual-disconnect.ts @@ -1,5 +1,7 @@ const manuallyDisconnectedEnvironmentIds = new Set() +export const RUNTIME_MANUALLY_DISCONNECTED_MESSAGE = 'Runtime environment is manually disconnected.' + export function markRuntimeEnvironmentManuallyDisconnected(environmentId: string): void { manuallyDisconnectedEnvironmentIds.add(environmentId) } diff --git a/src/main/ipc/runtime-import-limits.test.ts b/src/main/ipc/runtime-import-limits.test.ts new file mode 100644 index 00000000000..2eaeae603b0 --- /dev/null +++ b/src/main/ipc/runtime-import-limits.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + formatByteCeiling, + REMOTE_IMPORT_MAX_FILE_BYTES, + REMOTE_IMPORT_MAX_TOTAL_BYTES +} from './runtime-import-limits' + +describe('formatByteCeiling', () => { + it('renders a size one byte over a ceiling as larger than the ceiling', () => { + // "is 2 GB, over the 2 GB limit" reads like a broken check, not a big file. + expect(formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)).toBe('2 GB') + expect(formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES + 1)).toBe('2.1 GB') + }) + + it('leaves an exact ceiling as a whole number', () => { + expect(formatByteCeiling(REMOTE_IMPORT_MAX_TOTAL_BYTES)).toBe('8 GB') + expect(formatByteCeiling(1024)).toBe('1 KB') + }) + + it('scales through the units', () => { + expect(formatByteCeiling(512)).toBe('512 B') + expect(formatByteCeiling(1024 * 1024)).toBe('1 MB') + expect(formatByteCeiling(1024 ** 4)).toBe('1 TB') + }) + + it('rounds up rather than to nearest', () => { + expect(formatByteCeiling(1024 * 1024 + 1)).toBe('1.1 MB') + }) + + it('does not crash on zero', () => { + expect(formatByteCeiling(0)).toBe('0 B') + }) +}) diff --git a/src/main/ipc/runtime-import-limits.ts b/src/main/ipc/runtime-import-limits.ts new file mode 100644 index 00000000000..80fd680a24e --- /dev/null +++ b/src/main/ipc/runtime-import-limits.ts @@ -0,0 +1,18 @@ +// Why: staging streams slices at upload time and never holds a whole file, so +// these are user-safety ceilings on an unattended transfer, not memory guards. +// They stay until the drop UI can show progress and cancel a running upload. +export const REMOTE_IMPORT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024 +export const REMOTE_IMPORT_MAX_TOTAL_BYTES = 8 * 1024 * 1024 * 1024 + +/** Rounds up, so a size over a ceiling never renders as the ceiling itself. */ +export function formatByteCeiling(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + const rounded = Math.ceil(value * 10) / 10 + return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} ${units[unit]}` +} diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts new file mode 100644 index 00000000000..4d11bc18652 --- /dev/null +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -0,0 +1,438 @@ +import { lstat, mkdtemp, mkdir, rename, rm, symlink, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract' +import type * as RuntimeImportLimits from './runtime-import-limits' + +type RuntimeImportLimitsModule = typeof RuntimeImportLimits + +type ChunkCall = { + relativePath: string + contentBase64: string + append: boolean + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number + expectedExecutionHostId?: string +} +type RuntimeCallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } + +const callRuntimeEnvironment = + vi.fn< + ( + userDataPath: string, + environmentId: string, + method: string, + params: ChunkCall, + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number, + envelope?: unknown, + options?: RuntimeCallOptions + ) => unknown + >() + +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: Parameters) => + callRuntimeEnvironment(...args) +})) +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) +// Why: see filesystem-runtime-upload-staging.test.ts — a real over-limit fixture +// would allocate gigabytes on Windows. +vi.mock('./runtime-import-limits', async (importOriginal) => ({ + ...(await importOriginal()), + REMOTE_IMPORT_MAX_FILE_BYTES: 2 * 1024 * 1024 +})) + +const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } = + await import('./runtime-upload-file-stream') +const { + clearRuntimeEnvironmentManualDisconnect, + markRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE +} = await import('./runtime-environment-manual-disconnect') + +let workDir: string + +function chunkCalls(): ChunkCall[] { + return callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , params]) => params) +} + +function uploadedBytes(): Buffer { + return Buffer.concat(chunkCalls().map((call) => Buffer.from(call.contentBase64, 'base64'))) +} + +/** Mirrors what staging records, so tests exercise the real identity contract. */ +async function stagedIdentity(filePath: string): Promise { + const stat = await lstat(filePath) + return { + byteLength: stat.size, + inode: stat.ino, + deviceId: stat.dev, + modifiedAtMs: stat.mtimeMs + } +} + +async function baseArgs(sourceRootPath: string, entryPath?: string) { + return { + userDataPath: '/user-data', + environmentId: 'env-1', + sourceRootPath, + entryRelativePath: entryPath ?? '', + expected: await stagedIdentity(entryPath ? join(sourceRootPath, entryPath) : sourceRootPath), + worktree: 'wt-1', + relativePath: '.upload.tmp' + } +} + +/** A path whose identity was never measured; every field is deliberately absent. */ +function unstagedArgs(sourceRootPath: string, entryPath?: string) { + return { + userDataPath: '/user-data', + environmentId: 'env-1', + sourceRootPath, + entryRelativePath: entryPath ?? '', + expected: { byteLength: 0, inode: 0, deviceId: 0, modifiedAtMs: 0 }, + worktree: 'wt-1', + relativePath: '.upload.tmp' + } +} + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-stream-')) + callRuntimeEnvironment.mockReset() + callRuntimeEnvironment.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: {} }) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +describe('streamExternalFileToRuntime', () => { + it('sends a file larger than the old 25 MB cap as ordered append-only slices', async () => { + const size = RUNTIME_UPLOAD_SLICE_BYTES * 2 + 1234 + const contents = Buffer.alloc(size) + for (let index = 0; index < size; index += 1) { + contents[index] = index % 251 + } + const filePath = join(workDir, 'big.bin') + await writeFile(filePath, contents) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({ + byteLength: size + }) + + const calls = chunkCalls() + expect(calls).toHaveLength(3) + expect(calls.map((call) => call.append)).toEqual([false, true, true]) + expect(uploadedBytes().equals(contents)).toBe(true) + }) + + it('refuses a source whose size no longer matches what staging measured', async () => { + const filePath = join(workDir, 'grown.bin') + await writeFile(filePath, Buffer.alloc(1024)) + const staged = await stagedIdentity(filePath) + await writeFile(filePath, Buffer.alloc(2048)) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged }) + ).rejects.toThrow("File changed since it was staged: 'grown.bin'") + expect(chunkCalls()).toHaveLength(0) + }) + + it('refuses a source swapped for a different file of the same size', async () => { + const filePath = join(workDir, 'swapped.bin') + await writeFile(filePath, Buffer.alloc(2048, 0x41)) + const staged = await stagedIdentity(filePath) + + // A rename-into-place keeps the size and changes the inode. + const decoyPath = join(workDir, 'decoy.bin') + await writeFile(decoyPath, Buffer.alloc(2048, 0x42)) + await rename(decoyPath, filePath) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged }) + ).rejects.toThrow('File changed since it was staged') + expect(chunkCalls()).toHaveLength(0) + }) + + it('refuses a source rewritten in place at the same size after staging', async () => { + const filePath = join(workDir, 'rewritten.bin') + await writeFile(filePath, Buffer.alloc(2048, 0x41)) + const staged = await stagedIdentity(filePath) + + // Same inode and size; only the modification time moves. + await writeFile(filePath, Buffer.alloc(2048, 0x42)) + const bumped = new Date(staged.modifiedAtMs + 5_000) + await utimes(filePath, bumped, bumped) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged }) + ).rejects.toThrow('File changed since it was staged') + expect(chunkCalls()).toHaveLength(0) + }) + + it('aborts when the source is rewritten at the same size mid-transfer', async () => { + const filePath = join(workDir, 'racing.bin') + const size = RUNTIME_UPLOAD_SLICE_BYTES * 2 + await writeFile(filePath, Buffer.alloc(size, 0x41)) + const args = await baseArgs(filePath) + + let rewritten = false + callRuntimeEnvironment.mockImplementation(async () => { + if (!rewritten) { + rewritten = true + await writeFile(filePath, Buffer.alloc(size, 0x42)) + const bumped = new Date(args.expected.modifiedAtMs + 5_000) + await utimes(filePath, bumped, bumped) + } + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow('File changed during upload') + }) + + it('accepts a source that still matches its staged identity', async () => { + const filePath = join(workDir, 'same.bin') + await writeFile(filePath, Buffer.alloc(2048)) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({ + byteLength: 2048 + }) + }) + + it('refuses a file over the ceiling and names the source, not the temp path', async () => { + const filePath = join(workDir, 'clip.mp4') + await writeFile(filePath, Buffer.alloc(3 * 1024 * 1024)) + + // Why: relativePath here is '.upload.tmp', a path the user never chose. + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow( + "'clip.mp4' is 3 MB, over the 2 MB per-file remote import limit" + ) + expect(chunkCalls()).toHaveLength(0) + }) + + it('never buffers more than one slice per chunk', async () => { + const filePath = join(workDir, 'sliced.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 2)) + + await streamExternalFileToRuntime(await baseArgs(filePath)) + + for (const call of chunkCalls()) { + expect(Buffer.from(call.contentBase64, 'base64').byteLength).toBeLessThanOrEqual( + RUNTIME_UPLOAD_SLICE_BYTES + ) + } + }) + + it('creates an empty destination for a zero-byte source', async () => { + const filePath = join(workDir, 'empty.txt') + await writeFile(filePath, '') + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({ + byteLength: 0 + }) + + expect(chunkCalls()).toEqual([expect.objectContaining({ append: false, contentBase64: '' })]) + }) + + it('refuses to finish a zero-byte upload whose source gained content mid-write', async () => { + const filePath = join(workDir, 'grows.txt') + await writeFile(filePath, '') + const args = await baseArgs(filePath) + + callRuntimeEnvironment.mockImplementation(async () => { + await writeFile(filePath, 'content arrived during the empty write') + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow('File changed during upload') + }) + + it('carries the pairing revision and runtime id on every chunk', async () => { + const filePath = join(workDir, 'guarded.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10)) + + await streamExternalFileToRuntime({ + ...(await baseArgs(filePath)), + expectedEnvironmentPairingRevision: 41, + expectedEnvironmentRuntimeId: 'runtime-7' + }) + + const guards = callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , , , revision, , options]) => ({ + revision, + runtimeId: options?.expectedEnvironmentRuntimeId + })) + expect(guards).toEqual([ + { revision: 41, runtimeId: 'runtime-7' }, + { revision: 41, runtimeId: 'runtime-7' } + ]) + }) + + it('stops mid-transfer when the caller aborts instead of streaming the rest', async () => { + const filePath = join(workDir, 'abandoned.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 4)) + const controller = new AbortController() + + callRuntimeEnvironment.mockImplementation(async () => { + controller.abort(new Error('window closed')) + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), signal: controller.signal }) + ).rejects.toThrow('window closed') + // One slice went out before the abort; the other three never do. + expect(chunkCalls()).toHaveLength(1) + }) + + it('refuses to start once the caller has already aborted', async () => { + const filePath = join(workDir, 'never.bin') + await writeFile(filePath, Buffer.alloc(1024)) + const controller = new AbortController() + controller.abort(new Error('window closed')) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), signal: controller.signal }) + ).rejects.toThrow('window closed') + expect(chunkCalls()).toHaveLength(0) + }) + + it('passes the abort signal to every chunk so an in-flight request is cancelled', async () => { + const filePath = join(workDir, 'signalled.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10)) + const controller = new AbortController() + + await streamExternalFileToRuntime({ + ...(await baseArgs(filePath)), + signal: controller.signal + }) + + const signals = callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , , , , , options]) => options?.signal) + expect(signals).toEqual([controller.signal, controller.signal]) + }) + + it('stops at the failing chunk instead of sending the rest of the file', async () => { + const filePath = join(workDir, 'fails.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 3)) + callRuntimeEnvironment.mockResolvedValueOnce({ id: 'x', ok: true, result: {}, _meta: {} }) + callRuntimeEnvironment.mockResolvedValueOnce({ + id: 'x', + ok: false, + error: { code: 'write_failed', message: 'disk full' } + }) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow('disk full') + expect(chunkCalls()).toHaveLength(2) + }) + + // symlink() needs privileges or Developer Mode on Windows. + it.skipIf(process.platform === 'win32')('refuses a symlinked source', async () => { + const targetPath = join(workDir, 'secret.txt') + await writeFile(targetPath, 'secret') + const linkPath = join(workDir, 'link.txt') + await symlink(targetPath, linkPath) + + await expect(streamExternalFileToRuntime(unstagedArgs(linkPath))).rejects.toThrow( + 'Symlink not allowed' + ) + expect(chunkCalls()).toHaveLength(0) + }) + + it.skipIf(process.platform === 'win32')( + 'refuses a regular file reached through a symlinked directory inside the root', + async () => { + // Why: the symlink guard only lstats the entry itself, which sees a plain + // file here — realpath containment is the only thing that catches this. + const outsideDir = join(workDir, 'outside') + await mkdir(outsideDir) + await writeFile(join(outsideDir, 'secret.txt'), 'secret') + const rootPath = join(workDir, 'root') + await mkdir(rootPath) + await symlink(outsideDir, join(rootPath, 'sub')) + + await expect( + streamExternalFileToRuntime(unstagedArgs(rootPath, 'sub/secret.txt')) + ).rejects.toThrow('Path escaped upload root during upload') + expect(chunkCalls()).toHaveLength(0) + } + ) + + it('forwards the host ownership expectations into every chunk', async () => { + const filePath = join(workDir, 'owned.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10)) + + await streamExternalFileToRuntime({ + ...(await baseArgs(filePath)), + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 5, + expectedExecutionHostId: 'ssh:ssh-1' + }) + + const calls = callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , params]) => params) + expect(calls).toHaveLength(2) + for (const params of calls) { + expect(params).toMatchObject({ + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 5, + expectedExecutionHostId: 'ssh:ssh-1' + }) + } + }) + + it.skipIf(process.platform === 'win32')( + 'refuses a symlinked directory entry before it reaches the containment check', + async () => { + const outsidePath = join(workDir, 'outside.txt') + await writeFile(outsidePath, 'outside') + const rootPath = join(workDir, 'root') + await mkdir(rootPath) + await symlink(outsidePath, join(rootPath, 'escape.txt')) + + await expect( + streamExternalFileToRuntime(unstagedArgs(rootPath, 'escape.txt')) + ).rejects.toThrow('Symlink not allowed') + expect(chunkCalls()).toHaveLength(0) + } + ) +}) + +describe('manual disconnect during a transfer', () => { + afterEach(() => { + clearRuntimeEnvironmentManualDisconnect('env-1') + }) + + it('stops at the next slice once the environment is manually disconnected', async () => { + const filePath = join(workDir, 'disconnect.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 3, 7)) + callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => { + if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) { + markRuntimeEnvironmentManuallyDisconnected('env-1') + } + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow( + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE + ) + expect(chunkCalls()).toHaveLength(1) + }) + + it('refuses the first slice when the environment is already disconnected', async () => { + const filePath = join(workDir, 'disconnected.bin') + await writeFile(filePath, Buffer.alloc(16, 1)) + markRuntimeEnvironmentManuallyDisconnected('env-1') + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow( + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE + ) + expect(chunkCalls()).toHaveLength(0) + }) +}) diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts new file mode 100644 index 00000000000..28271774ce0 --- /dev/null +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -0,0 +1,213 @@ +import { constants, type Stats } from 'node:fs' +import { lstat, open, realpath } from 'node:fs/promises' +import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' +import type { + RuntimeUploadFileStreamRequest, + StagedRuntimeUploadFileIdentity +} from '../../shared/runtime-upload-staging-contract' +import { authorizeExternalPath } from './filesystem-auth' +import { formatByteCeiling, REMOTE_IMPORT_MAX_FILE_BYTES } from './runtime-import-limits' +import { + isRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE +} from './runtime-environment-manual-disconnect' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' + +// Why: base64 turns 3 bytes into 4 chars, so a 384 KiB slice lands on the wire +// as exactly 512 KiB — the chunk size the renderer used before streaming. +export const RUNTIME_UPLOAD_SLICE_BYTES = 384 * 1024 + +const RUNTIME_UPLOAD_CHUNK_TIMEOUT_MS = 30_000 + +export type RuntimeUploadFileStreamArgs = RuntimeUploadFileStreamRequest & { + /** Resolved environment id, not a selector: the manual-disconnect check keys on it. */ + environmentId: string + userDataPath: string + /** Aborts the transfer; the caller's lifetime is what raises it today. */ + signal?: AbortSignal +} + +/** + * Stream one client-local file to a runtime environment in slices. + * + * Replaces reading the whole file into memory and base64-encoding it before the + * first byte moves. Peak memory is one slice, so imports are no longer bounded + * by main-process heap. + */ +export async function streamExternalFileToRuntime( + args: RuntimeUploadFileStreamArgs +): Promise<{ byteLength: number }> { + const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath) + + // Why: parity with staging — an OS drop authorizes the paths it hands over. + authorizeExternalPath(sourcePath) + + // Why: relativePath is the hidden .orca-upload- temp destination, so a + // dropped file names its source instead of a path the user never chose. + const displayPath = args.entryRelativePath || basename(args.sourceRootPath) + const lstatResult = await lstat(sourcePath) + if (lstatResult.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${displayPath}'`) + } + if (!lstatResult.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if (args.entryRelativePath) { + await assertEntryInsideRoot(args.sourceRootPath, sourcePath, displayPath) + } + assertMatchesStagedIdentity(lstatResult, args.expected, displayPath) + + args.signal?.throwIfAborted() + + const handle = await open(sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const openedStat = await handle.stat() + if (!openedStat.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if (!isSameFile(openedStat, lstatResult)) { + throw new Error(`File changed during upload: '${displayPath}'`) + } + // Why: the handle is what the slices are read from, so the staged identity + // has to hold here too — checking only the pre-open lstat leaves a window + // where the path is swapped between lstat and open. + assertMatchesStagedIdentity(openedStat, args.expected, displayPath) + + const totalBytes = openedStat.size + // Why: enforced again where the bytes actually move. Staging is a separate + // call, so the ceiling only holds here if this boundary checks it too. + if (totalBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + throw new Error( + `'${displayPath}' is ${formatByteCeiling(totalBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + ) + } + if (totalBytes === 0) { + // Why: a zero-byte source produces no slices, but the destination still + // has to exist before commitUpload renames it into place. + await sendChunk(args, '', false) + } else { + const buffer = Buffer.allocUnsafe(Math.min(RUNTIME_UPLOAD_SLICE_BYTES, totalBytes)) + let offset = 0 + while (offset < totalBytes) { + // Why: checked per slice, so an abort stops the transfer at the next + // boundary instead of after the whole file has moved. + args.signal?.throwIfAborted() + const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) + if (bytesRead === 0) { + throw new Error(`File truncated during upload: '${displayPath}'`) + } + await sendChunk(args, buffer.subarray(0, bytesRead).toString('base64'), offset > 0) + offset += bytesRead + } + } + + // Why: the destination is a temp path the caller commits, so a source + // rewritten mid-transfer is caught before anything lands at the final path. + // mtime catches an in-place edit that kept the size. An empty source runs + // this too: its chunk is still a round trip the source can change during. + const afterReadStat = await handle.stat() + if (afterReadStat.mtimeMs !== openedStat.mtimeMs || !isSameFile(afterReadStat, openedStat)) { + throw new Error(`File changed during upload: '${displayPath}'`) + } + return { byteLength: totalBytes } + } finally { + await handle.close() + } +} + +/** + * Refuse a source that no longer matches what staging measured. + * + * Inode and device are compared only when both sides report one, because some + * filesystems leave them at 0; size and mtime then carry the check alone. + */ +function assertMatchesStagedIdentity( + observed: Stats, + expected: StagedRuntimeUploadFileIdentity, + displayPath: string +): void { + const changed = + observed.size !== expected.byteLength || + observed.mtimeMs !== expected.modifiedAtMs || + (expected.inode !== 0 && observed.ino !== 0 && observed.ino !== expected.inode) || + (expected.deviceId !== 0 && observed.dev !== 0 && observed.dev !== expected.deviceId) + if (changed) { + throw new Error(`File changed since it was staged: '${displayPath}'`) + } +} + +/** Same inode on the same device, where the filesystem reports them. */ +function isSameFile(a: Stats, b: Stats): boolean { + return ( + a.size === b.size && + (a.ino === 0 || b.ino === 0 || a.ino === b.ino) && + (a.dev === 0 || b.dev === 0 || a.dev === b.dev) + ) +} + +/** Append one base64 slice, carrying the host guards that must hold per chunk. */ +async function sendChunk( + args: RuntimeUploadFileStreamArgs, + contentBase64: string, + append: boolean +): Promise { + // Why: the renderer's per-chunk calls went through an IPC handler that refuses + // a manually disconnected environment. The loop lives in main now, so it makes + // the same check, or a disconnect mid-upload keeps pushing bytes to that host. + if (isRuntimeEnvironmentManuallyDisconnected(args.environmentId)) { + throw new Error(RUNTIME_MANUALLY_DISCONNECTED_MESSAGE) + } + const response = await callRuntimeEnvironment( + args.userDataPath, + args.environmentId, + 'files.writeBase64Chunk', + { + worktree: args.worktree, + relativePath: args.relativePath, + contentBase64, + append, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, + expectedExecutionHostId: args.expectedExecutionHostId + }, + RUNTIME_UPLOAD_CHUNK_TIMEOUT_MS, + // Why: re-checked per chunk, so a re-pair mid-upload aborts instead of + // appending the rest of the file on a different host. + args.expectedEnvironmentPairingRevision, + undefined, + { + // Why: a replacement runtime keeps the pairing but invalidates its + // predecessor's capability proof, so the identity rides every chunk too. + expectedEnvironmentRuntimeId: args.expectedEnvironmentRuntimeId, + signal: args.signal + } + ) + if (response.ok !== true) { + throw new Error(response.error.message || response.error.code) + } +} + +function resolveEntrySourcePath(sourceRootPath: string, entryRelativePath: string): string { + // Why: staging resolves before authorizing, so the streamer has to agree on + // the same absolute path or the two checks can disagree. + const root = resolve(sourceRootPath) + return entryRelativePath ? join(root, entryRelativePath) : root +} + +async function assertEntryInsideRoot( + sourceRootPath: string, + candidatePath: string, + displayPath: string +): Promise { + const rootRealPath = await realpath(sourceRootPath) + const candidateRealPath = await realpath(candidatePath) + const relativeToRoot = relative(rootRealPath, candidateRealPath) + // Why: `..name` is a valid child path; only `..` and `../...` escape. + if ( + relativeToRoot !== '' && + (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) + ) { + throw new Error(`Path escaped upload root during upload: '${displayPath}'`) + } +} diff --git a/src/main/ipc/runtime-upload-slice-boundaries.test.ts b/src/main/ipc/runtime-upload-slice-boundaries.test.ts new file mode 100644 index 00000000000..0c2bd457b1f --- /dev/null +++ b/src/main/ipc/runtime-upload-slice-boundaries.test.ts @@ -0,0 +1,383 @@ +import { + appendFile, + mkdir, + mkdtemp, + readFile, + rm, + stat, + truncate, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FileWriteBase64Chunk } from '../../shared/rpc-contract/files-mutation-params' +import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract' + +// Why: real limits, real host write flags ('wx' then 'a') and the real chunk +// schema — the slice loop is exercised exactly at the boundaries it must respect. +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) + +type ChunkParams = { relativePath: string; contentBase64: string; append: boolean } +type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } +type CallArgs = [ + userDataPath: string, + environmentId: string, + method: string, + params: ChunkParams, + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number, + envelope?: unknown, + options?: CallOptions +] + +const callRuntimeEnvironment = vi.fn<(...args: CallArgs) => Promise>() +// Why: vi.fn retains every call's params; a 2 GiB stream would pin ~2.8 GB of +// base64 in mock.calls and masquerade as a leak. Big tests swap in a plain fn. +let transportImpl: (...args: CallArgs) => Promise = (...args) => + callRuntimeEnvironment(...args) +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: CallArgs) => transportImpl(...args) +})) + +const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } = + await import('./runtime-upload-file-stream') +const { stageOneSourceForRuntimeUpload } = await import('./filesystem-runtime-upload-staging') +const { REMOTE_IMPORT_MAX_FILE_BYTES, REMOTE_IMPORT_MAX_TOTAL_BYTES, formatByteCeiling } = + await import('./runtime-import-limits') + +const SLICE = RUNTIME_UPLOAD_SLICE_BYTES +const WIRE_CHUNK_CHARS = 512 * 1024 +const OK = { id: 'x', ok: true, result: {}, _meta: {} } + +let workDir: string +let remoteDir: string + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-bounds-')) + remoteDir = join(workDir, 'remote') + await mkdir(remoteDir) + callRuntimeEnvironment.mockReset() + callRuntimeEnvironment.mockResolvedValue(OK) + transportImpl = (...args) => callRuntimeEnvironment(...args) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +function chunkCalls(): ChunkParams[] { + return callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , params]) => params) +} + +/** Mirrors the host: first chunk is an exclusive create, appends open with 'a'. */ +function installRealHostWrites(): void { + callRuntimeEnvironment.mockImplementation(async (_u, _e, method, params) => { + if (method === 'files.writeBase64Chunk') { + const parsed = FileWriteBase64Chunk.parse({ worktree: 'wt-1', ...params }) + await writeFile( + join(remoteDir, parsed.relativePath), + Buffer.from(parsed.contentBase64, 'base64'), + { + flag: parsed.append ? 'a' : 'wx' + } + ) + } + return OK + }) +} + +async function identityOf(path: string): Promise { + const s = await stat(path) + return { byteLength: s.size, inode: s.ino, deviceId: s.dev, modifiedAtMs: s.mtimeMs } +} + +async function argsFor(sourceRootPath: string, entryRelativePath = '', relativePath = 'dest.tmp') { + const target = entryRelativePath ? join(sourceRootPath, entryRelativePath) : sourceRootPath + return { + userDataPath: '/user-data', + environmentId: 'env-1', + sourceRootPath, + entryRelativePath, + expected: await identityOf(target), + worktree: 'wt-1', + relativePath, + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'rt-1' + } +} + +function patterned(size: number, seed: number): Buffer { + const buffer = Buffer.allocUnsafe(size) + for (let i = 0; i < size; i += 1) { + buffer[i] = (i * 31 + seed) & 0xff + } + return buffer +} + +describe('slice boundaries', () => { + const sizes = [ + 1, + 2, + 3, + 4, + SLICE - 1, + SLICE, + SLICE + 1, + 2 * SLICE - 1, + 2 * SLICE, + 2 * SLICE + 1, + 3 * SLICE + 7 + ] + + for (const size of sizes) { + it(`streams ${size} bytes as ceil(size/slice) schema-valid chunks that the host reassembles exactly`, async () => { + installRealHostWrites() + const contents = patterned(size, size) + const source = join(workDir, `s-${size}.bin`) + await writeFile(source, contents) + const dest = `dest-${size}.tmp` + + await expect(streamExternalFileToRuntime(await argsFor(source, '', dest))).resolves.toEqual({ + byteLength: size + }) + + const calls = chunkCalls() + const expectedChunks = Math.ceil(size / SLICE) + expect(calls).toHaveLength(expectedChunks) + expect(calls.map((c) => c.append)).toEqual(calls.map((_, i) => i > 0)) + for (const [index, call] of calls.entries()) { + const isLast = index === calls.length - 1 + expect(call.contentBase64.length).toBeLessThanOrEqual(WIRE_CHUNK_CHARS) + if (!isLast) { + expect(call.contentBase64.length).toBe(WIRE_CHUNK_CHARS) + } + expect(call.relativePath).toBe(dest) + } + const remote = await readFile(join(remoteDir, dest)) + expect(remote.equals(contents)).toBe(true) + }) + } + + it('sends a zero-byte file as one empty exclusive create the host schema accepts', async () => { + installRealHostWrites() + const source = join(workDir, 'empty.bin') + await writeFile(source, '') + + await expect( + streamExternalFileToRuntime(await argsFor(source, '', 'empty.tmp')) + ).resolves.toEqual({ + byteLength: 0 + }) + expect(chunkCalls()).toHaveLength(1) + expect(chunkCalls()[0]).toMatchObject({ + relativePath: 'empty.tmp', + contentBase64: '', + append: false + }) + expect((await stat(join(remoteDir, 'empty.tmp'))).size).toBe(0) + }) + + it('carries the pairing revision, runtime id and signal on every chunk', async () => { + const source = join(workDir, 'guards.bin') + await writeFile(source, patterned(2 * SLICE + 1, 3)) + + await streamExternalFileToRuntime(await argsFor(source)) + + const chunkInvocations = callRuntimeEnvironment.mock.calls.filter( + ([, , method]) => method === 'files.writeBase64Chunk' + ) + expect(chunkInvocations).toHaveLength(3) + for (const [, environmentId, , , timeoutMs, revision, envelope, options] of chunkInvocations) { + expect(environmentId).toBe('env-1') + expect(timeoutMs).toBe(30_000) + expect(revision).toBe(7) + expect(envelope).toBeUndefined() + expect(options?.expectedEnvironmentRuntimeId).toBe('rt-1') + } + }) +}) + +describe('staging → streaming end to end on a real filesystem', () => { + it('streams every staged entry of a dropped directory using the identity staging recorded', async () => { + installRealHostWrites() + const root = join(workDir, 'drop me') + await mkdir(join(root, 'sub', 'deeper'), { recursive: true }) + const files: Record = { + 'a.txt': Buffer.from('alpha'), + '..keep': Buffer.from('dot-dot-prefixed name is a valid child'), + 'héllo wörld.bin': patterned(SLICE, 9), + 'sub/empty': Buffer.alloc(0), + 'sub/deeper/big.bin': patterned(2 * SLICE + 5, 11) + } + for (const [rel, body] of Object.entries(files)) { + await writeFile(join(root, rel), body) + } + + const staged = await stageOneSourceForRuntimeUpload(root) + expect(staged.status).toBe('staged') + if (staged.status !== 'staged') { + return + } + const fileEntries = staged.entries.filter((e) => e.kind === 'file') + expect(fileEntries.map((e) => e.relativePath).sort()).toEqual(Object.keys(files).sort()) + + for (const entry of fileEntries) { + if (entry.kind !== 'file') { + continue + } + const dest = `up-${entry.relativePath.replace(/[^a-z0-9]/gi, '_')}.tmp` + await expect( + streamExternalFileToRuntime({ + userDataPath: '/u', + environmentId: 'env-1', + sourceRootPath: staged.sourcePath, + entryRelativePath: entry.relativePath, + expected: { + byteLength: entry.byteLength, + inode: entry.inode, + deviceId: entry.deviceId, + modifiedAtMs: entry.modifiedAtMs + }, + worktree: 'wt-1', + relativePath: dest + }) + ).resolves.toEqual({ byteLength: files[entry.relativePath]!.length }) + const remote = await readFile(join(remoteDir, dest)) + expect(remote.equals(files[entry.relativePath]!)).toBe(true) + } + }) + + it('streams a dropped single file using the identity staging recorded', async () => { + installRealHostWrites() + const source = join(workDir, 'single.bin') + const body = patterned(SLICE + 1, 5) + await writeFile(source, body) + + const staged = await stageOneSourceForRuntimeUpload(source) + expect(staged.status).toBe('staged') + if (staged.status !== 'staged') { + return + } + const entry = staged.entries[0]! + expect(entry.kind).toBe('file') + if (entry.kind !== 'file') { + return + } + + await expect( + streamExternalFileToRuntime({ + userDataPath: '/u', + environmentId: 'env-1', + sourceRootPath: staged.sourcePath, + entryRelativePath: entry.relativePath, + expected: entry, + worktree: 'wt-1', + relativePath: 'single.tmp' + }) + ).resolves.toEqual({ byteLength: body.length }) + expect((await readFile(join(remoteDir, 'single.tmp'))).equals(body)).toBe(true) + }) +}) + +describe('source mutation during transfer', () => { + it('rejects a source that grows during the transfer and never claims success', async () => { + const source = join(workDir, 'growing.bin') + await writeFile(source, patterned(2 * SLICE, 1)) + const args = await argsFor(source) + callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => { + if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) { + await appendFile(source, 'extra') + } + return OK + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow( + "File changed during upload: 'growing.bin'" + ) + }) + + it('rejects a source truncated during the transfer instead of sending a short file', async () => { + const source = join(workDir, 'shrinking.bin') + await writeFile(source, patterned(3 * SLICE, 2)) + const args = await argsFor(source) + callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => { + if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) { + await truncate(source, SLICE) + } + return OK + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow( + "File truncated during upload: 'shrinking.bin'" + ) + expect(chunkCalls().length).toBeLessThan(3) + }) + + it('accepts a staged identity whose inode and device are unreported (0) when size and mtime match', async () => { + const source = join(workDir, 'no-ino.bin') + await writeFile(source, patterned(10, 4)) + const args = await argsFor(source) + args.expected = { ...args.expected, inode: 0, deviceId: 0 } + + await expect(streamExternalFileToRuntime(args)).resolves.toEqual({ byteLength: 10 }) + }) + + it('still refuses a wrong inode when only the device is unreported', async () => { + const source = join(workDir, 'wrong-ino.bin') + await writeFile(source, patterned(10, 4)) + const args = await argsFor(source) + args.expected = { ...args.expected, inode: args.expected.inode + 1, deviceId: 0 } + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow( + "File changed since it was staged: 'wrong-ino.bin'" + ) + expect(chunkCalls()).toHaveLength(0) + }) + + it('stops before the next slice when the signal aborts while a chunk is in flight', async () => { + const source = join(workDir, 'abort.bin') + await writeFile(source, patterned(3 * SLICE, 6)) + const controller = new AbortController() + callRuntimeEnvironment.mockImplementation(async (_u, _e, method, _p, _t, _r, _env, options) => { + if (method !== 'files.writeBase64Chunk') { + return OK + } + if (chunkCalls().length === 2) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { + once: true + }) + controller.abort(new Error('window gone')) + }) + } + return OK + }) + + await expect( + streamExternalFileToRuntime({ ...(await argsFor(source)), signal: controller.signal }) + ).rejects.toThrow('window gone') + expect(chunkCalls()).toHaveLength(2) + }) +}) + +describe('formatByteCeiling bounds', () => { + it.each([ + [0, '0 B'], + [1, '1 B'], + [1023, '1023 B'], + [1024, '1 KB'], + [1025, '1.1 KB'], + [25 * 1024 * 1024, '25 MB'], + [25 * 1024 * 1024 + 1, '25.1 MB'], + [REMOTE_IMPORT_MAX_FILE_BYTES, '2 GB'], + [REMOTE_IMPORT_MAX_FILE_BYTES + 1, '2.1 GB'], + [REMOTE_IMPORT_MAX_TOTAL_BYTES, '8 GB'], + [REMOTE_IMPORT_MAX_TOTAL_BYTES + 1, '8.1 GB'], + [1024 ** 5, '1024 TB'] + ])('%i → %s', (bytes, text) => { + expect(formatByteCeiling(bytes)).toBe(text) + }) +}) diff --git a/src/main/ipc/runtime-upload-temp-sweep.test.ts b/src/main/ipc/runtime-upload-temp-sweep.test.ts new file mode 100644 index 00000000000..06e7fe9282c --- /dev/null +++ b/src/main/ipc/runtime-upload-temp-sweep.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' + +const callRuntimeEnvironment = + vi.fn< + ( + userDataPath: string, + environmentId: string, + method: string, + params: { relativePath: string; recursive: boolean }, + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number, + envelope?: unknown, + options?: { expectedEnvironmentRuntimeId?: string } + ) => unknown + >() + +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: Parameters) => + callRuntimeEnvironment(...args) +})) + +const { sweepAbandonedRuntimeUploadTempPath } = await import('./runtime-upload-temp-sweep') + +const request: RuntimeUploadFileStreamRequest = { + environmentId: 'env-1', + sourceRootPath: '/Users/me/clip.mp4', + entryRelativePath: '', + expected: { byteLength: 4, inode: 1, deviceId: 2, modifiedAtMs: 3 }, + worktree: 'id:wt-1', + relativePath: 'uploads/.clip.mp4.orca-upload-abc', + expectedEnvironmentPairingRevision: 17, + expectedEnvironmentRuntimeId: 'runtime-7', + expectedExecutionHostId: 'local' +} + +function deleteCalls(): { relativePath: string; recursive: boolean }[] { + return callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.delete') + .map(([, , , params]) => params) +} + +beforeEach(() => { + vi.useFakeTimers() + callRuntimeEnvironment.mockReset() + callRuntimeEnvironment.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: {} }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('sweepAbandonedRuntimeUploadTempPath', () => { + it('deletes twice, because a straggling append recreates the file with flag a', async () => { + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + await swept + + expect(deleteCalls()).toEqual([ + expect.objectContaining({ relativePath: request.relativePath, recursive: false }), + expect.objectContaining({ relativePath: request.relativePath, recursive: false }) + ]) + }) + + it('still makes the second pass when the first one fails', async () => { + callRuntimeEnvironment.mockRejectedValueOnce(new Error('connection lost')) + + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + await expect(swept).resolves.toBeUndefined() + + expect(deleteCalls()).toHaveLength(2) + }) + + it('carries the host ownership guards so it cannot delete on a re-paired host', async () => { + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + await swept + + for (const call of callRuntimeEnvironment.mock.calls) { + expect(call[5]).toBe(17) + expect(call[7]?.expectedEnvironmentRuntimeId).toBe('runtime-7') + } + }) + + it('never rejects, so cleanup cannot mask the upload failure', async () => { + callRuntimeEnvironment.mockRejectedValue(new Error('runtime gone')) + + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + + await expect(swept).resolves.toBeUndefined() + }) +}) diff --git a/src/main/ipc/runtime-upload-temp-sweep.ts b/src/main/ipc/runtime-upload-temp-sweep.ts new file mode 100644 index 00000000000..46150fd96e5 --- /dev/null +++ b/src/main/ipc/runtime-upload-temp-sweep.ts @@ -0,0 +1,49 @@ +import { setTimeout } from 'node:timers/promises' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' + +const RUNTIME_UPLOAD_SWEEP_ATTEMPTS = 2 +const RUNTIME_UPLOAD_SWEEP_SETTLE_MS = 250 + +/** + * Sweep an abandoned upload temp path after an abort. + * + * Aborting rejects the in-flight chunk locally, but the host may still apply + * that append — and appends open with `flag: 'a'`, which recreates the file a + * delete just removed. Slices are strictly sequential, so at most one append + * can be outstanding: a second pass after it has had time to land is enough. + * + * Best-effort throughout. The runtime may be why the upload failed, and a + * failed cleanup of a hidden temp file is not actionable. + */ +export async function sweepAbandonedRuntimeUploadTempPath( + userDataPath: string, + args: RuntimeUploadFileStreamRequest +): Promise { + for (let attempt = 0; attempt < RUNTIME_UPLOAD_SWEEP_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await setTimeout(RUNTIME_UPLOAD_SWEEP_SETTLE_MS) + } + try { + await callRuntimeEnvironment( + userDataPath, + args.environmentId, + 'files.delete', + { + worktree: args.worktree, + relativePath: args.relativePath, + recursive: false, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, + expectedExecutionHostId: args.expectedExecutionHostId + }, + 15_000, + args.expectedEnvironmentPairingRevision, + undefined, + { expectedEnvironmentRuntimeId: args.expectedEnvironmentRuntimeId } + ) + } catch { + // Nothing to escalate; the next pass (if any) still runs. + } + } +} diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 478f3040f72..21acebad53a 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -12,6 +12,10 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { + RuntimeUploadFileStreamRequest, + StageRuntimeUploadResult +} from '../../shared/runtime-upload-staging-contract' export type ExportApi = { htmlToPdf: (args: { @@ -155,30 +159,12 @@ export type FilesystemApi = { } )[] }> - stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] }) => Promise<{ - sources: ( - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: ( - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - )[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> + stageExternalPathsForRuntimeUpload: (args: { + sourcePaths: string[] + }) => Promise + uploadExternalFileToRuntime: ( + args: RuntimeUploadFileStreamRequest + ) => Promise<{ byteLength: number }> resolveDroppedPathsForAgent: ( args: { paths: string[] diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 207b34d8519..05c6eaee477 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,6 +1,10 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' import { ipcRenderer } from 'electron' import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { + RuntimeUploadFileStreamRequest, + StageRuntimeUploadResult +} from '../../shared/runtime-upload-staging-contract' import type { SearchResult } from '../../shared/code-search-types' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' import type { @@ -174,30 +178,11 @@ export const fsApi = { }> => ipcRenderer.invoke('fs:importExternalPaths', args), stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] - }): Promise<{ - sources: ( - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: ( - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - )[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + }): Promise => + ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + uploadExternalFileToRuntime: ( + args: RuntimeUploadFileStreamRequest + ): Promise<{ byteLength: number }> => ipcRenderer.invoke('fs:uploadExternalFileToRuntime', args), resolveDroppedPathsForAgent: ( args: { paths: string[] diff --git a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts index fb0f0e8e092..52f0bb83d09 100644 --- a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts @@ -4,6 +4,7 @@ import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revisi import { fsImportExternalPaths, fsStageExternalPathsForRuntimeUpload, + fsUploadExternalFileToRuntime, runtimeEnvironmentCall, runtimeEnvironmentTransportCall, installRuntimeFileClientEnvironment @@ -11,11 +12,65 @@ import { installRuntimeFileClientEnvironment() +const okResponse = (id: string): unknown => ({ + id, + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } +}) + +const notFoundResponse = (id: string): unknown => ({ + id, + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } +}) + +/** Matches what main-process staging now records for a file entry. */ +const stagedFile = ( + relativePath: string, + byteLength: number, + inode: number +): Record => ({ + relativePath, + kind: 'file', + byteLength, + inode, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 +}) + +/** The upload request main receives; `never[]` mock args widen to it without a cast. */ +type UploadRequest = { + environmentId: string + sourceRootPath: string + entryRelativePath: string + expected: Record + worktree: string + relativePath: string + expectedExecutionHostId?: string + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId?: string +} + +function uploadRequests(): UploadRequest[] { + return fsUploadExternalFileToRuntime.mock.calls.flat() +} + +const identityOf = (entry: Record): Record => ({ + byteLength: entry.byteLength, + inode: entry.inode, + deviceId: entry.deviceId, + modifiedAtMs: entry.modifiedAtMs +}) + describe('runtime file client', () => { - it('uploads a staged directory after one ownership and one cold compatibility preflight', async () => { + it('streams staged directory entries through main instead of sending base64 itself', async () => { replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 17 }]) - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'BBBBBBBB' + const logo = stagedFile('logo.png', 3, 101) + const large = stagedFile('large.bin', 40 * 1024 * 1024, 102) fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -23,85 +78,19 @@ describe('runtime file client', () => { status: 'staged', name: 'assets', kind: 'directory', - entries: [ - { relativePath: '', kind: 'directory' }, - { relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' }, - { - relativePath: 'large.bin', - kind: 'file', - contentBase64: `${firstChunk}${secondChunk}` - } - ] + entries: [{ relativePath: '', kind: 'directory' }, logo, large] } ] }) runtimeEnvironmentCall - .mockResolvedValueOnce({ - id: 'stat-destination-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-destination-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'stat-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-file', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'commit-upload', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'commit-large-upload', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-large-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('create-dir')) + .mockResolvedValueOnce(okResponse('commit-upload')) + .mockResolvedValueOnce(okResponse('delete-temp')) + .mockResolvedValueOnce(okResponse('commit-large-upload')) + .mockResolvedValueOnce(okResponse('delete-large-temp')) await expect( importExternalPathsToRuntime( @@ -136,75 +125,48 @@ describe('runtime file client', () => { 'files.createDir', 'files.stat', 'files.createDirNoClobber', - 'files.writeBase64', 'files.commitUpload', 'files.delete', - 'files.writeBase64Chunk', - 'files.writeBase64Chunk', 'files.commitUpload', 'files.delete' ]) - expect(transportCalls.filter((args) => args.method === 'status.get')).toHaveLength(2) + // Why: the whole point of the change — no file body crosses this boundary. + expect(transportCalls.some((args) => String(args.method).startsWith('files.writeBase64'))).toBe( + false + ) expect(transportCalls.every((args) => args.expectedEnvironmentPairingRevision === 17)).toBe( true ) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { - selector: 'env-1', - method: 'files.stat', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads' - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { - selector: 'env-1', - method: 'files.createDir', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads', - expectedExecutionHostId: 'local' - }, - timeoutMs: 15_000, + + const uploads = uploadRequests() + expect(uploads).toHaveLength(2) + expect(uploads[0]?.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) + expect(uploads[0]).toEqual({ + environmentId: 'env-1', + sourceRootPath: '/Users/me/assets', + entryRelativePath: 'logo.png', + expected: identityOf(logo), + worktree: 'id:wt-1', + relativePath: uploads[0]?.relativePath, + expectedExecutionHostId: 'local', + expectedSshTargetId: undefined, + expectedSshConnectionGeneration: undefined, expectedEnvironmentPairingRevision: 17, expectedEnvironmentRuntimeId: 'remote-runtime' }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { - selector: 'env-1', - method: 'files.stat', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads/assets' - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17 + expect(uploads[1]).toMatchObject({ + entryRelativePath: 'large.bin', + expected: identityOf(large) }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { - selector: 'env-1', - method: 'files.createDirNoClobber', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads/assets', - expectedExecutionHostId: 'local' - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - const smallWriteCall = runtimeEnvironmentCall.mock.calls[4]?.[0] as { - params: { relativePath: string } - } - expect(smallWriteCall.params.relativePath).toMatch( - /^uploads\/assets\/\.logo\.png\.orca-upload-/ - ) + expect(uploads[1]?.relativePath).toMatch(/^uploads\/assets\/\.large\.bin\.orca-upload-/) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { selector: 'env-1', - method: 'files.writeBase64', + method: 'files.commitUpload', params: { worktree: 'id:wt-1', - relativePath: smallWriteCall.params.relativePath, - contentBase64: 'cG5n', + tempRelativePath: uploads[0]?.relativePath, + finalRelativePath: 'uploads/assets/logo.png', expectedExecutionHostId: 'local', expectedSshTargetId: undefined, expectedSshConnectionGeneration: undefined @@ -214,99 +176,11 @@ describe('runtime file client', () => { expectedEnvironmentRuntimeId: 'remote-runtime' }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { - selector: 'env-1', - method: 'files.commitUpload', - params: { - worktree: 'id:wt-1', - tempRelativePath: smallWriteCall.params.relativePath, - finalRelativePath: 'uploads/assets/logo.png', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { selector: 'env-1', method: 'files.delete', params: { worktree: 'id:wt-1', - relativePath: smallWriteCall.params.relativePath, - recursive: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - const largeWriteParams = runtimeEnvironmentCall.mock.calls[7]?.[0].params - if ( - typeof largeWriteParams !== 'object' || - largeWriteParams === null || - !('relativePath' in largeWriteParams) || - typeof largeWriteParams.relativePath !== 'string' - ) { - throw new Error('missing large file write call') - } - const largeWriteRelativePath = largeWriteParams.relativePath - expect(largeWriteRelativePath).toMatch(/^uploads\/assets\/\.large\.bin\.orca-upload-/) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(8, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: largeWriteRelativePath, - contentBase64: firstChunk, - append: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(9, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: largeWriteRelativePath, - contentBase64: secondChunk, - append: true, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(10, { - selector: 'env-1', - method: 'files.commitUpload', - params: { - worktree: 'id:wt-1', - tempRelativePath: largeWriteRelativePath, - finalRelativePath: 'uploads/assets/large.bin', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(11, { - selector: 'env-1', - method: 'files.delete', - params: { - worktree: 'id:wt-1', - relativePath: largeWriteRelativePath, + relativePath: uploads[0]?.relativePath, recursive: false, expectedExecutionHostId: 'local', expectedSshTargetId: undefined, @@ -319,9 +193,8 @@ describe('runtime file client', () => { expect(fsImportExternalPaths).not.toHaveBeenCalled() }) - it('chunks large staged runtime uploads below the WebSocket frame budget', async () => { - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'AA==' + it('forwards a single staged file with the identity staging measured', async () => { + const entry = stagedFile('', 40 * 1024 * 1024, 55) fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -329,55 +202,16 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [ - { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } - ] + entries: [entry] } ] }) runtimeEnvironmentCall - .mockResolvedValueOnce({ - id: 'stat-destination-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-destination-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'stat-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'commit-upload', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('commit-upload')) + .mockResolvedValueOnce(okResponse('delete-temp')) await expect( importExternalPathsToRuntime( @@ -401,79 +235,20 @@ describe('runtime file client', () => { ] }) - const chunkWriteCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as { - params: { relativePath: string } - } - expect(chunkWriteCall.params.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - contentBase64: firstChunk, - append: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - contentBase64: secondChunk, - append: true, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { - selector: 'env-1', - method: 'files.commitUpload', - params: { - worktree: 'id:wt-1', - tempRelativePath: chunkWriteCall.params.relativePath, - finalRelativePath: 'uploads/large.bin', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { - selector: 'env-1', - method: 'files.delete', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - recursive: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) + const upload = uploadRequests()[0] + expect(upload?.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/) + expect(upload?.sourceRootPath).toBe('/Users/me/large.bin') + expect(upload?.entryRelativePath).toBe('') + expect(upload?.expected).toEqual(identityOf(entry)) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.writeBase64' }) ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.writeBase64Chunk' }) + ) }) - it('stops a chunked upload when its owner generation changes between writes', async () => { - const firstChunk = 'A'.repeat(512 * 1024) + it('does not commit an upload when the owner generation changes while it streams', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -481,7 +256,7 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64: `${firstChunk}BBBBBBBB` }] + entries: [stagedFile('', 40 * 1024 * 1024, 55)] } ] }) @@ -492,22 +267,12 @@ describe('runtime file client', () => { result: { size: 0, isDirectory: true, mtime: 1 }, _meta: { runtimeId: 'remote-runtime' } }) - .mockResolvedValueOnce({ - id: 'stat-file-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockImplementationOnce(async () => { - ownerChanged = true - return { - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - } - }) + .mockResolvedValueOnce(notFoundResponse('stat-file-miss')) let ownerChanged = false + fsUploadExternalFileToRuntime.mockImplementation(async () => { + ownerChanged = true + return { byteLength: 40 * 1024 * 1024 } + }) const assertCurrent = vi.fn(() => { if (ownerChanged) { throw new Error('runtime owner generation changed') @@ -531,20 +296,14 @@ describe('runtime file client', () => { expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ 'files.stat', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) - expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( - expect.objectContaining({ method: 'files.delete' }) - ) }) - it('cleans up staged runtime upload temp files when a later chunk fails', async () => { - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'BBBBBBBB' + it('cleans up the staged temp path when the streamed upload fails', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -552,49 +311,19 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [ - { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } - ] + entries: [stagedFile('', 40 * 1024 * 1024, 55)] } ] }) runtimeEnvironmentCall - .mockResolvedValueOnce({ - id: 'stat-destination-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-destination-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'stat-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('delete-temp')) + // Electron wraps a main-process throw; the reason must not leak that. + fsUploadExternalFileToRuntime.mockRejectedValue( + new Error("Error invoking remote method 'fs:uploadExternalFileToRuntime': Error: disk full") + ) await expect( importExternalPathsToRuntime( @@ -610,13 +339,7 @@ describe('runtime file client', () => { results: [{ status: 'failed', reason: 'disk full' }] }) - const chunkCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as - | { params: { relativePath: string } } - | undefined - if (!chunkCall) { - throw new Error('missing first chunk call') - } - const tempRelativePath = chunkCall.params.relativePath + const tempRelativePath = uploadRequests()[0]?.relativePath expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) @@ -645,10 +368,7 @@ describe('runtime file client', () => { status: 'staged', name: 'assets', kind: 'directory', - entries: [ - { relativePath: '', kind: 'directory' }, - { relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' } - ] + entries: [{ relativePath: '', kind: 'directory' }, stagedFile('logo.png', 3, 101)] } ] }) @@ -659,36 +379,11 @@ describe('runtime file client', () => { result: { size: 0, isDirectory: true, mtime: 1 }, _meta: { runtimeId: 'remote-runtime' } }) - .mockResolvedValueOnce({ - id: 'stat-import-root-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-import-root', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-file', - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-import-root', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-import-root-miss')) + .mockResolvedValueOnce(okResponse('create-import-root')) + .mockResolvedValueOnce(okResponse('delete-temp')) + .mockResolvedValueOnce(okResponse('delete-import-root')) + fsUploadExternalFileToRuntime.mockRejectedValue(new Error('disk full')) await expect( importExternalPathsToRuntime( @@ -704,13 +399,7 @@ describe('runtime file client', () => { results: [{ status: 'failed', reason: 'disk full' }] }) - const writeCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as - | { params: { relativePath: string } } - | undefined - if (!writeCall) { - throw new Error('missing failed file write call') - } - expect(writeCall.params.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) + expect(uploadRequests()[0]?.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ selector: 'env-1', method: 'files.delete', @@ -763,6 +452,7 @@ describe('runtime file client', () => { expectedSshConnectionGeneration: 5 }) expect(fsStageExternalPathsForRuntimeUpload).not.toHaveBeenCalled() + expect(fsUploadExternalFileToRuntime).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/runtime/runtime-file-client-test-harness.ts b/src/renderer/src/runtime/runtime-file-client-test-harness.ts index 844080626be..321c6abe6d1 100644 --- a/src/renderer/src/runtime/runtime-file-client-test-harness.ts +++ b/src/renderer/src/runtime/runtime-file-client-test-harness.ts @@ -54,6 +54,7 @@ export const fsFinishDownloadedFile: PreloadStub = vi.fn() export const fsCancelDownloadedFile: PreloadStub = vi.fn() export const fsImportExternalPaths: PreloadStub = vi.fn() export const fsStageExternalPathsForRuntimeUpload: PreloadStub = vi.fn() +export const fsUploadExternalFileToRuntime: PreloadStub = vi.fn() export const runtimeEnvironmentCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentTransportCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentSubscribe: RuntimeSubscribeStub = vi.fn() @@ -88,6 +89,8 @@ export function installRuntimeFileClientEnvironment(): void { fsCancelDownloadedFile.mockReset() fsImportExternalPaths.mockReset() fsStageExternalPathsForRuntimeUpload.mockReset() + fsUploadExternalFileToRuntime.mockReset() + fsUploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() runtimeEnvironmentSubscribe.mockReset() @@ -131,7 +134,8 @@ export function installRuntimeFileClientEnvironment(): void { finishDownloadedFile: fsFinishDownloadedFile, cancelDownloadedFile: fsCancelDownloadedFile, importExternalPaths: fsImportExternalPaths, - stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload + stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload, + uploadExternalFileToRuntime: fsUploadExternalFileToRuntime }, runtime: { call: runtimeCall }, runtimeEnvironments: { diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 7e274343d0a..cfbec0ac146 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -21,25 +21,6 @@ import { import { getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -type StagedRuntimeImportSource = - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: StagedRuntimeImportEntry[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { sourcePath: string; status: 'failed'; reason: string } - -type StagedRuntimeImportEntry = - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - type RuntimeImportResult = | { sourcePath: string @@ -113,7 +94,7 @@ export async function importExternalPathsToRuntime( await ensureRuntimeDirectory(context, destinationDir, importSession) - for (const source of staged.sources as StagedRuntimeImportSource[]) { + for (const source of staged.sources) { if (source.status !== 'staged') { results.push(source) continue @@ -150,7 +131,16 @@ export async function importExternalPathsToRuntime( importSession, context.worktreeId, entryRelativePath, - entry.contentBase64, + { + sourceRootPath: source.sourcePath, + entryRelativePath: entry.relativePath, + expected: { + byteLength: entry.byteLength, + inode: entry.inode, + deviceId: entry.deviceId, + modifiedAtMs: entry.modifiedAtMs + } + }, context.expectedSshConnectionGeneration, context.expectedSshTargetId, context.expectedExecutionHostId ?? diff --git a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts index 34da21c8966..eb8dad53970 100644 --- a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts +++ b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts @@ -32,6 +32,7 @@ type RuntimeCallArgs = { const runtimeEnvironmentCall = vi.fn<(args: RuntimeCallArgs) => unknown>() const stageExternalPathsForRuntimeUpload = vi.fn() +const uploadExternalFileToRuntime = vi.fn<(args: Record) => unknown>() const importExternalPaths = vi.fn() const nestedSshContext = { @@ -99,7 +100,8 @@ function repairedRuntimeResponse(method: string) { } } -function mockStagedFile(sourcePath: string, name: string, contentBase64: string): void { +/** Staging now hands over identity, not a body; the streamer in main reads the bytes. */ +function mockStagedFile(sourcePath: string, name: string, byteLength: number): void { stageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -107,12 +109,28 @@ function mockStagedFile(sourcePath: string, name: string, contentBase64: string) status: 'staged', name, kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64 }] + entries: [ + { + relativePath: '', + kind: 'file', + byteLength, + inode: 91, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 + } + ] } ] }) } +function expectUploadsBoundToCapturedRevision(): void { + for (const [args] of uploadExternalFileToRuntime.mock.calls) { + expect(args.expectedEnvironmentPairingRevision).toBe(CAPTURED_REVISION) + expect(args.expectedEnvironmentRuntimeId).toBe('hub-runtime') + } +} + function expectEveryRuntimeCallBoundToCapturedRevision(ownership: { expectedExecutionHostId: string expectedSshTargetId?: string @@ -146,12 +164,15 @@ beforeEach(() => { markRuntimeEnvironmentCompatible(ENVIRONMENT_ID) runtimeEnvironmentCall.mockReset() stageExternalPathsForRuntimeUpload.mockReset() + uploadExternalFileToRuntime.mockReset() + uploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) importExternalPaths.mockReset() vi.stubGlobal('window', { api: { fs: { importExternalPaths, - stageExternalPathsForRuntimeUpload + stageExternalPathsForRuntimeUpload, + uploadExternalFileToRuntime }, runtimeEnvironments: { call: runtimeEnvironmentCall @@ -183,7 +204,7 @@ describe('runtime file import pairing revision', () => { }) it('stops when the HUB runtime changes without a pairing change', async () => { - mockStagedFile('/client/screenshot.png', 'screenshot.png', `${'A'.repeat(512 * 1024)}BBBBBBBB`) + mockStagedFile('/client/screenshot.png', 'screenshot.png', 40 * 1024 * 1024) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -191,14 +212,15 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64Chunk') { - setRuntimeEnvironmentConnectionGenerationForTests( - ENVIRONMENT_ID, - REPLACEMENT_CONNECTION_GENERATION - ) - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockImplementation(async () => { + setRuntimeEnvironmentConnectionGenerationForTests( + ENVIRONMENT_ID, + REPLACEMENT_CONNECTION_GENERATION + ) + return { byteLength: 40 * 1024 * 1024 } + }) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/screenshot.png'], '/ssh/repo') @@ -208,9 +230,9 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) + expectUploadsBoundToCapturedRevision() expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) @@ -236,8 +258,8 @@ describe('runtime file import pairing revision', () => { expect(importExternalPaths).not.toHaveBeenCalled() }) - it('stops a rich-markdown upload between chunks without contacting the replacement HUB', async () => { - mockStagedFile('/client/screenshot.png', 'screenshot.png', `${'A'.repeat(512 * 1024)}BBBBBBBB`) + it('never commits a streamed upload against a replacement HUB re-paired mid-stream', async () => { + mockStagedFile('/client/screenshot.png', 'screenshot.png', 40 * 1024 * 1024) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -245,11 +267,12 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64Chunk') { - setEnvironmentRevision(REPLACEMENT_REVISION) - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockImplementation(async () => { + setEnvironmentRevision(REPLACEMENT_REVISION) + return { byteLength: 40 * 1024 * 1024 } + }) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/screenshot.png'], '/ssh/repo') @@ -259,20 +282,9 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - method: 'files.writeBase64Chunk', - expectedEnvironmentPairingRevision: CAPTURED_REVISION, - params: expect.objectContaining({ - contentBase64: 'A'.repeat(512 * 1024), - append: false - }) - }) - ) + expectUploadsBoundToCapturedRevision() expectEveryRuntimeCallBoundToCapturedRevision(nestedSshContext) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) @@ -283,7 +295,7 @@ describe('runtime file import pairing revision', () => { }) it('keeps a HUB-local composer commit on its entry revision when re-paired during commit', async () => { - mockStagedFile('/client/note.txt', 'note.txt', 'bm90ZQ==') + mockStagedFile('/client/note.txt', 'note.txt', 4) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.expectedEnvironmentPairingRevision !== CAPTURED_REVISION) { throw new Error('replacement HUB received an import RPC') @@ -310,14 +322,13 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', 'files.stat', - 'files.writeBase64', 'files.commitUpload' ]) expectEveryRuntimeCallBoundToCapturedRevision(hubLocalContext) }) it('does not clean up against a replacement HUB after commit', async () => { - mockStagedFile('/client/drop.txt', 'drop.txt', 'ZHJvcA==') + mockStagedFile('/client/drop.txt', 'drop.txt', 4) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -340,7 +351,6 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', 'files.stat', - 'files.writeBase64', 'files.commitUpload' ]) expectEveryRuntimeCallBoundToCapturedRevision(nestedSshContext) @@ -356,7 +366,14 @@ describe('runtime file import pairing revision', () => { kind: 'directory', entries: [ { relativePath: '', kind: 'directory' }, - { relativePath: 'broken.txt', kind: 'file', contentBase64: 'YnJva2Vu' } + { + relativePath: 'broken.txt', + kind: 'file', + byteLength: 6, + inode: 92, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 + } ] } ] @@ -368,16 +385,9 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64') { - return { - id: args.method, - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'hub-runtime' } - } - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockRejectedValue(new Error('disk full')) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/assets'], '/ssh/repo') @@ -387,7 +397,6 @@ describe('runtime file import pairing revision', () => { 'status.get', 'files.stat', 'files.createDirNoClobber', - 'files.writeBase64', 'files.delete', 'files.delete' ]) diff --git a/src/renderer/src/runtime/runtime-file-upload-client.ts b/src/renderer/src/runtime/runtime-file-upload-client.ts index 2f3c1582ec5..fc3d0f83a9a 100644 --- a/src/renderer/src/runtime/runtime-file-upload-client.ts +++ b/src/renderer/src/runtime/runtime-file-upload-client.ts @@ -1,4 +1,6 @@ +import { extractIpcErrorMessage } from '@/lib/ipc-error' import { joinPath, normalizeRelativePath } from '@/lib/path' +import type { StagedRuntimeUploadFileIdentity } from '../../../shared/runtime-upload-staging-contract' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { callRuntimeFileImportMutation, @@ -12,28 +14,48 @@ import { import { runtimePathExists } from './runtime-file-metadata-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024 +/** Locates a staged file on the client so main can stream it without the renderer reading it. */ +export type RuntimeUploadSource = { + sourceRootPath: string + entryRelativePath: string + /** What staging observed; main refuses the upload if the source no longer matches. */ + expected: StagedRuntimeUploadFileIdentity +} +/** Stream one staged file to a temp path, then commit it; the temp path is always cleaned up. */ export async function uploadRuntimeFileWithoutClobber( session: RuntimeFileImportSession, worktreeId: string, relativePath: string, - contentBase64: string, + source: RuntimeUploadSource, expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, expectedExecutionHostId?: 'local' | `ssh:${string}` ): Promise { const tempRelativePath = makeRuntimeUploadTempPath(relativePath) try { - await writeRuntimeBase64File( - session, - worktreeId, - tempRelativePath, - contentBase64, - expectedSshConnectionGeneration, - expectedSshTargetId, - expectedExecutionHostId - ) + session.assertCurrent() + // Why: main owns the file handle and the runtime socket, so it streams the + // body in slices; the renderer never holds the whole file. + try { + await window.api.fs.uploadExternalFileToRuntime({ + environmentId: session.target.environmentId, + sourceRootPath: source.sourceRootPath, + entryRelativePath: source.entryRelativePath, + expected: source.expected, + worktree: toRuntimeWorktreeSelector(worktreeId), + relativePath: tempRelativePath, + expectedSshTargetId, + expectedSshConnectionGeneration, + expectedExecutionHostId, + expectedEnvironmentPairingRevision: session.expectedEnvironmentPairingRevision, + expectedEnvironmentRuntimeId: session.expectedEnvironmentRuntimeId + }) + } catch (error) { + // Why: this surfaces in the import result as-is, and Electron wraps a + // main-process throw in "Error invoking remote method '…'". + throw new Error(extractIpcErrorMessage(error, 'Upload failed')) + } await callRuntimeFileImportMutation( session, 'files.commitUpload', @@ -64,50 +86,7 @@ export async function uploadRuntimeFileWithoutClobber( } } -async function writeRuntimeBase64File( - session: RuntimeFileImportSession, - worktreeId: string, - relativePath: string, - contentBase64: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: 'local' | `ssh:${string}` -): Promise { - if (contentBase64.length <= REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { - await callRuntimeFileImportMutation( - session, - 'files.writeBase64', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - relativePath, - contentBase64, - expectedSshTargetId, - expectedSshConnectionGeneration, - expectedExecutionHostId - }, - 30_000 - ) - return - } - - for (let offset = 0; offset < contentBase64.length; offset += REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { - await callRuntimeFileImportMutation( - session, - 'files.writeBase64Chunk', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - relativePath, - contentBase64: contentBase64.slice(offset, offset + REMOTE_UPLOAD_BASE64_CHUNK_CHARS), - append: offset > 0, - expectedSshTargetId, - expectedSshConnectionGeneration, - expectedExecutionHostId - }, - 30_000 - ) - } -} - +/** Hidden sibling of the destination, so a failed upload never leaves a plausible-looking file. */ function makeRuntimeUploadTempPath(relativePath: string): string { const normalized = normalizeRelativePath(relativePath) const slashIndex = normalized.lastIndexOf('/') diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index dcf93839828..889e89b5103 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -126,6 +126,11 @@ export function createFileApi(): NonNullable['fs']> { }, importExternalPaths: async () => ({ results: [] }), stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), + // Why: the web client has no local filesystem to stream from, so staging + // never yields a source for this to upload. + uploadExternalFileToRuntime: async () => { + throw new Error('Uploading local files is not supported in the web client') + }, resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), watchWorktree: () => Promise.resolve(), unwatchWorktree: () => Promise.resolve(), diff --git a/src/shared/runtime-upload-staging-contract.ts b/src/shared/runtime-upload-staging-contract.ts new file mode 100644 index 00000000000..19a3d2f6261 --- /dev/null +++ b/src/shared/runtime-upload-staging-contract.ts @@ -0,0 +1,50 @@ +import type { SshMutationExpectation } from './ssh-types' + +export type RuntimeUploadSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + +/** + * What staging observed about a file, so the uploader can refuse a source that + * was swapped between the two calls. Size alone misses a same-size replacement. + */ +export type StagedRuntimeUploadFileIdentity = { + byteLength: number + /** 0 when the filesystem does not report one; compared only when both sides have it. */ + inode: number + deviceId: number + modifiedAtMs: number +} + +export type StagedRuntimeUploadEntry = + | { relativePath: string; kind: 'directory' } + // Why: file bodies are streamed in slices at upload time, so staging carries + // identity the uploader re-checks against the handle it actually reads. + | ({ relativePath: string; kind: 'file' } & StagedRuntimeUploadFileIdentity) + +export type StagedRuntimeUploadSource = + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: StagedRuntimeUploadEntry[] + } + | { sourcePath: string; status: 'skipped'; reason: RuntimeUploadSkipReason } + | { sourcePath: string; status: 'failed'; reason: string } + +export type StageRuntimeUploadResult = { sources: StagedRuntimeUploadSource[] } + +/** Renderer → main request to pump one staged file's bytes to the runtime. */ +export type RuntimeUploadFileStreamRequest = { + environmentId: string + /** Client-local path of the dropped source (file, or root of a dropped directory). */ + sourceRootPath: string + /** Path of this file within the dropped directory; empty when the source is a file. */ + entryRelativePath: string + /** Identity staging recorded; a source that no longer matches is refused, not streamed. */ + expected: StagedRuntimeUploadFileIdentity + worktree: string + /** Destination path on the runtime, relative to the worktree. */ + relativePath: string + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId?: string +} & SshMutationExpectation From 389d672dab3963f098f269a36f0ffb4a148a7d90 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:17:04 -0700 Subject: [PATCH 23/43] fix(omp): preserve saved conversation names in session history (#20636) Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- docs/reference/omp-history-titles.md | 34 +++++ .../ai-vault/session-scanner-graph-parsers.ts | 54 +++++++- .../session-scanner-omp-title.test.ts | 131 ++++++++++++++++++ .../ai-vault/session-scanner-omp-title.ts | 49 +++++++ tests/tools/omp-history-title-smoke.mjs | 92 ++++++++++++ 5 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 docs/reference/omp-history-titles.md create mode 100644 src/main/ai-vault/session-scanner-omp-title.test.ts create mode 100644 src/main/ai-vault/session-scanner-omp-title.ts create mode 100644 tests/tools/omp-history-title-smoke.mjs diff --git a/docs/reference/omp-history-titles.md b/docs/reference/omp-history-titles.md new file mode 100644 index 00000000000..f238574ef2a --- /dev/null +++ b/docs/reference/omp-history-titles.md @@ -0,0 +1,34 @@ +# OMP history titles + +The message-graph scanner uses persisted OMP names ahead of the first user prompt: +`session.title`, version-1 `title` slots, `title_change.title`, and legacy +`session_info.name`. Empty or unsupported metadata leaves the previous name or +prompt fallback intact. Non-OMP graph parsing keeps its existing title policy. + +Explicit user names outrank automatic names. Within the same source, timestamps +prevent the current first-line title slot from being replaced by older rename +entries later in the file. Newer appended renames still update the row. Legacy +records without timestamps retain file-order handling. + +The graph fold stores title authority alongside the existing accumulator. Clones +retain it without sharing mutable accumulator or preview state, while preserving +the existing identity and message-consumer contracts. Cached append parsing uses +the normal durable offset; no extra scan, process, poll or watcher is introduced. + +The parser is shared by local and remote content readers and uses transcript data +from the execution host. It performs no client-side path lookup and changes no +wire shape. Folder workspaces require no git metadata. + +Run actual persistence and cache validation with a read-only OMP checkout: + +```sh +ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-history-title-smoke.mjs /path/to/oh-my-pi +``` + +The smoke persists a first prompt, performs a real OMP user rename, and verifies +both cold and incrementally cached scans. It checks one full parse, one append +parse and identical-object reuse on an unchanged scan. All home/config/data roots +are disposable; no model requests are made. + +This is the OMP subset of the history-name behavior proposed in PR #15696 by +Brennan Benson. Pi naming and title changes in the terminal are separate concerns. diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 3ca4e754106..353decfcd46 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,3 +1,4 @@ +import { foldOmpTranscriptTitle, type OmpTranscriptTitle } from './session-scanner-omp-title' import { remoteSessionContentLines, type RemoteSessionContent @@ -15,7 +16,8 @@ import type { } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { - accumulatorFoldResumeState, + accumulatorSessionIdentity, + cloneSessionAccumulator, addPreviewContent, addPreviewMessage, createAccumulator, @@ -212,12 +214,24 @@ export async function parseMessageGraphSessionContent( }) } -function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: string): void { +type MessageGraphParseState = { + accumulator: SessionAccumulator + ompTitle: OmpTranscriptTitle | null +} + +function consumeMessageGraphRecordLine(state: MessageGraphParseState, line: string): void { + const { accumulator } = state const record = parseJsonObject(line) if (!record) { return } updateTimeline(accumulator, extractString(record.timestamp)) + if (accumulator.agent === 'omp') { + state.ompTitle = foldOmpTranscriptTitle(state.ompTitle, record) + if (state.ompTitle) { + accumulator.title = state.ompTitle.title + } + } if (record.type === 'session') { const sessionId = extractString(record.id) if (sessionId) { @@ -241,7 +255,11 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st if (role === 'user' || role === 'assistant') { accumulator.messageCount++ if (role === 'user') { - accumulator.title ??= extractMessageText(message) + if (accumulator.agent === 'omp') { + accumulator.fallbackTitle ??= extractMessageText(message) + } else { + accumulator.title ??= extractMessageText(message) + } } else { accumulator.model = extractString(message?.model) ?? accumulator.model accumulator.totalTokens += tokenTotal(message?.usage) @@ -255,16 +273,38 @@ export function createMessageGraphSessionResumeState( file: FileWithMtime, messages?: TranscriptMessageSink ): ResumableSessionParseState { - const state = accumulatorFoldResumeState( - createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path), messages }), - consumeMessageGraphRecordLine - ) + const state = createMessageGraphResumeState({ + accumulator: createAccumulator({ + agent, + file, + sessionId: sessionIdFromFileName(file.path), + messages + }), + ompTitle: null + }) // Why: only OMP materializes task-subagent transcripts beside its sessions // (in the same-named artifact dir); the row UI shows the count without // expanding details. Pi/OpenClaw/Prime Agent have no such layout — skip the readdir. return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state } +function createMessageGraphResumeState(state: MessageGraphParseState): ResumableSessionParseState { + return { + consumeLine: (line) => consumeMessageGraphRecordLine(state, line), + identity: () => accumulatorSessionIdentity(state.accumulator), + clone: () => + createMessageGraphResumeState({ + accumulator: cloneSessionAccumulator(state.accumulator), + ompTitle: state.ompTitle + }), + touchFile: (file) => { + state.accumulator.modifiedAt = file.modifiedAt + }, + finalize: (platform, options) => + finalizeSession(cloneSessionAccumulator(state.accumulator), platform, options) + } +} + async function parseMessageGraphSessionLines(args: { agent: MessageGraphAgent file: FileWithMtime diff --git a/src/main/ai-vault/session-scanner-omp-title.test.ts b/src/main/ai-vault/session-scanner-omp-title.test.ts new file mode 100644 index 00000000000..3e2d6a966cf --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-title.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { + createMessageGraphSessionResumeState, + parseMessageGraphSessionContent +} from './session-scanner-graph-parsers' + +const file = { path: '/tmp/omp-title.jsonl', mtimeMs: 1, modifiedAt: '2026-09-14T00:00:00.000Z' } +const prompt = { type: 'message', message: { role: 'user', content: 'First prompt' } } +const header = { type: 'session', id: 'session-id', cwd: '/folder workspace' } +const line = (record: unknown) => JSON.stringify(record) +async function parse(records: unknown[], agent: 'omp' | 'pi' = 'omp') { + return parseMessageGraphSessionContent( + agent, + file, + [header, ...records].map(line).join('\n'), + 'darwin' + ) +} + +describe('OMP stored history names', () => { + it.each([ + { type: 'session', title: 'Harness name', titleSource: 'user' }, + { + type: 'title', + v: 1, + title: 'Harness name', + source: 'user', + updatedAt: '2026-09-14T01:00:00Z', + pad: '' + }, + { type: 'title_change', title: 'Harness name', source: 'user' }, + { type: 'session_info', name: 'Harness name' } + ])('uses persisted %j ahead of the first prompt', async (record) => { + expect((await parse([prompt, record]))?.title).toBe('Harness name') + }) + + it('preserves a user name through stale header and later automatic records', async () => { + expect( + ( + await parse([ + { + type: 'title', + v: 1, + title: 'User name', + source: 'user', + updatedAt: '2026-09-14T02:00:00Z', + pad: '' + }, + { ...header, title: 'Old header' }, + prompt, + { + type: 'title_change', + title: 'Auto name', + source: 'auto', + timestamp: '2026-09-14T03:00:00Z' + } + ]) + )?.title + ).toBe('User name') + }) + + it('keeps the current slot ahead of older rename entries, allowing a newer rename', async () => { + const records = [ + { + type: 'title', + v: 1, + title: 'Current slot', + source: 'user', + updatedAt: '2026-09-14T02:00:00Z', + pad: '' + }, + prompt, + { + type: 'title_change', + title: 'Old rename', + source: 'user', + timestamp: '2026-09-14T01:00:00Z' + } + ] + expect((await parse(records))?.title).toBe('Current slot') + expect( + ( + await parse([ + ...records, + { + type: 'title_change', + title: 'New rename', + source: 'user', + timestamp: '2026-09-14T03:00:00Z' + } + ]) + )?.title + ).toBe('New rename') + }) + + it('preserves fallback behavior for missing, empty or unsupported title records', async () => { + expect( + ( + await parse([ + prompt, + { type: 'title_change', title: ' ', source: 'user' }, + { type: 'title_change', title: 'Unknown', source: 'model' }, + { type: 'session_info', title: 'Wrong field' } + ]) + )?.title + ).toBe('First prompt') + expect( + (await parse([prompt, { type: 'title_change', title: 'OMP only', source: 'user' }], 'pi')) + ?.title + ).toBe('First prompt') + }) + + it('clones title authority for append parsing without mutating previous snapshots', async () => { + const state = createMessageGraphSessionResumeState('omp', file) + for (const record of [ + header, + prompt, + { type: 'title_change', title: 'User name', source: 'user' } + ]) { + state.consumeLine(line(record)) + } + const previous = await state.finalize('darwin') + const next = state.clone() + next.consumeLine(line({ type: 'title_change', title: 'Auto name', source: 'auto' })) + expect((await next.finalize('darwin'))?.title).toBe('User name') + next.consumeLine(line({ type: 'title_change', title: 'New name', source: 'user' })) + expect((await next.finalize('darwin'))?.title).toBe('New name') + expect(previous?.title).toBe('User name') + expect(state.identity?.()?.title).toBe('User name') + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-title.ts b/src/main/ai-vault/session-scanner-omp-title.ts new file mode 100644 index 00000000000..e734350caad --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-title.ts @@ -0,0 +1,49 @@ +import { extractString, normalizeTitleText, timestampMs } from './session-scanner-values' + +export type OmpTranscriptTitle = { + title: string + source: 'user' | 'auto' + updatedAt: number | null +} + +/** Fold persisted title metadata; a current slot can precede older rename entries. */ +export function foldOmpTranscriptTitle( + current: OmpTranscriptTitle | null, + record: Record +): OmpTranscriptTitle | null { + const legacy = record.type === 'session_info' + if ( + !legacy && + record.type !== 'session' && + record.type !== 'title_change' && + record.type !== 'title' + ) { + return current + } + if (record.type === 'title' && record.v !== 1) { + return current + } + const title = normalizeTitleText(extractString(legacy ? record.name : record.title) ?? '') + if (!title) { + return current + } + const rawSource = legacy ? 'user' : (record.source ?? record.titleSource) + if (rawSource !== undefined && rawSource !== 'user' && rawSource !== 'auto') { + return current + } + const source = rawSource === 'user' ? 'user' : 'auto' + if (current?.source === 'user' && source !== 'user') { + return current + } + const timestamp = timestampMs(record.type === 'title' ? record.updatedAt : record.timestamp) + const updatedAt = Number.isFinite(timestamp) ? timestamp : null + if ( + current?.source === source && + current.updatedAt !== null && + updatedAt !== null && + updatedAt < current.updatedAt + ) { + return current + } + return { title, source, updatedAt } +} diff --git a/tests/tools/omp-history-title-smoke.mjs b/tests/tools/omp-history-title-smoke.mjs new file mode 100644 index 00000000000..7c5d84aaa2c --- /dev/null +++ b/tests/tools/omp-history-title-smoke.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +assert.ok(process.argv[2], 'Pass a read-only OMP checkout') +const orcaRoot = fileURLToPath(new URL('../../', import.meta.url)) +const scratch = await mkdtemp(join(tmpdir(), 'orca-omp-history-title-')) +process.env.HOME = join(scratch, 'home') +process.env.USERPROFILE = process.env.HOME +for (const [key, value] of Object.entries({ + XDG_CONFIG_HOME: 'config', + XDG_DATA_HOME: 'data', + XDG_STATE_HOME: 'state', + XDG_CACHE_HOME: 'cache' +})) { + process.env[key] = join(scratch, value) +} +for (const key of [ + 'OMP_CODING_AGENT_DIR', + 'PI_CODING_AGENT_DIR', + 'OMP_PROFILE', + 'PI_PROFILE', + 'PI_CONFIG_DIR', + 'PI_CONFIG_FILES' +]) { + delete process.env[key] +} +await mkdir(process.env.HOME, { recursive: true }) +const source = (root, path) => pathToFileURL(join(resolve(root), path)).href +const { SessionManager } = await import( + source(process.argv[2], 'packages/coding-agent/src/session/session-manager.ts') +) +const { parseMessageGraphSessionFile } = await import( + source(orcaRoot, 'src/main/ai-vault/session-scanner-graph-parsers.ts') +) +const { createSessionParseStats, parseAgentSessionFileCached } = await import( + source(orcaRoot, 'src/main/ai-vault/session-scanner-parse-cache.ts') +) +const stats = createSessionParseStats() +const manager = SessionManager.create(scratch, join(scratch, 'sessions')) +try { + manager.appendMessage({ role: 'user', content: 'Original first prompt', timestamp: Date.now() }) + await manager.ensureOnDisk() + await manager.flush() + const candidate = async () => { + const details = await stat(manager.getSessionFile()) + return { + agent: 'omp', + codexHome: null, + file: { + path: manager.getSessionFile(), + mtimeMs: details.mtimeMs, + modifiedAt: details.mtime.toISOString(), + sizeBytes: details.size + } + } + } + const initial = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(initial.title, 'Original first prompt') + await manager.setSessionName('Explicit renamed conversation', 'user') + await manager.flush() + const path = manager.getSessionFile() + const details = await stat(path) + const parsed = await parseMessageGraphSessionFile( + 'omp', + { path, mtimeMs: details.mtimeMs, modifiedAt: details.mtime.toISOString() }, + process.platform + ) + const refreshed = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(parsed?.title, manager.getSessionName()) + assert.equal(refreshed?.title, manager.getSessionName()) + assert.equal(stats.fullParses, 1) + assert.equal(stats.incremental, 1) + assert.equal(initial.title, 'Original first prompt') + const reused = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(reused, refreshed) + console.log( + JSON.stringify({ + actualOmpPersistence: true, + renamedTitlePreserved: true, + cachedRenamePreserved: true, + unchangedSnapshotReused: true, + fullParses: stats.fullParses, + incrementalParses: stats.incremental, + modelCalls: 0 + }) + ) +} finally { + await manager.close() + await rm(scratch, { recursive: true, force: true }) +} From 46eb5959fa783e55852ce14aa0123b7b3e3f423a Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:27:31 +0900 Subject: [PATCH 24/43] fix(ui): contain idle caret paint so agent panes stop burning CPU (#10554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An idle agent pane kept ~40% of a core busy just by being frontmost. The xterm cursor and the native chat caret blink with no paint-containment boundary, so Chromium treated each blink as damage to the whole pane ancestry and re-rasterized it twice a second. - `.xterm-container` and the native composer's input shell get `contain: paint`, bounding blink damage to the surface that blinks. - The mention hint gains `z-20` to match the slash picker: a contained element becomes a stacking context and paints at z-index 0 in tree order, which would otherwise cover the hint's drop shadow. Also records that DECSCUSR pins `decPrivateModes.cursorBlink`, which wins over the option in `_updateCursorBlink` — so parking `cursorBlink` does not reliably stop a hidden pane blinking. Pre-existing, documented only. Co-authored-by: Wooseong Kim --- .../terminal-container-geometry.test.ts | 4 +++ src/renderer/src/assets/terminal.css | 4 +++ .../NativeChatAutocompleteMenus.tsx | 5 ++- .../native-chat/NativeChatComposerField.tsx | 10 +++++- .../native-chat-composer-containment.test.ts | 33 +++++++++++++++++++ .../pane-cursor-blink-suspension.ts | 8 ++++- 6 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts index db079c9095f..cf17cb00540 100644 --- a/src/renderer/src/assets/terminal-container-geometry.test.ts +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -15,4 +15,8 @@ describe('terminal container geometry', () => { /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s ) }) + + it('bounds cursor-blink repaints to the terminal surface (#10481)', () => { + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*contain:\s*paint;/s) + }) }) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 1ee09586892..0d17b1f77df 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -512,6 +512,10 @@ height: calc(100% - var(--pane-padding-y, 4px)); margin-top: var(--pane-padding-y, 4px); margin-left: var(--pane-padding-x, 4px); + /* Why (#10481): a blinking cursor otherwise invalidates paint all the way up + the pane ancestry. The link tooltip and drag handle are .pane siblings, so + clipping to this box costs no visible chrome. */ + contain: paint; } /* When a pane has a title, shift the terminal content down to make room. diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 125d4326b8f..2e1d05e0ffe 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -276,7 +276,10 @@ export function NativeChatMentionHint({ event.preventDefault() onAccept() }} - className="absolute bottom-full left-3 right-3 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4" + // Why z-20: matches the slash picker. The composer shell below is a paint + // containment boundary (#10481), so it now paints at z-index 0 in tree + // order and would otherwise cover this hint's drop shadow. + className="absolute bottom-full left-3 right-3 z-20 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4" > {translate('components.native-chat.composer.mentionHint', 'Referencing file:')}{' '} @{query || '…'} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index f51bf4c5400..a4b104efd0f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -192,7 +192,15 @@ export function NativeChatComposerField({ // no focus/click border flash. The box is a container, not a // focus target. 'rounded-lg border border-border p-1.5 shadow-xs', - 'bg-muted/50 dark:bg-input/40' + 'bg-muted/50 dark:bg-input/40', + // Why (#10481): the native caret blink invalidates paint up to the + // nearest containment boundary; without this the whole transcript + // re-rasterizes twice a second. Pickers are siblings and every menu + // and tooltip in here is a Radix portal, so nothing floating clips. + // Tightest descendant is the attachment remove button, which + // overhangs its thumbnail by 6px and clears this box's padding by + // 4px — keep that slack if the padding below ever shrinks. + '[contain:paint]' )} > {imageAttachments.length > 0 ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts new file mode 100644 index 00000000000..2c8a08ffa89 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const composerField = fs.readFileSync( + new URL('./NativeChatComposerField.tsx', import.meta.url), + 'utf8' +) +const autocompleteMenus = fs.readFileSync( + new URL('./NativeChatAutocompleteMenus.tsx', import.meta.url), + 'utf8' +) + +describe('native chat composer paint containment (#10481)', () => { + it('bounds caret repaints to the composer input shell', () => { + expect(composerField).toContain('[contain:paint]') + }) + + it('keeps the outer composer uncontained so the pickers can overflow it', () => { + // The pickers are siblings that render above the shell via `bottom-full`; + // containing their parent would clip them. + const outerShell = composerField.slice(0, composerField.indexOf('[contain:paint]')) + expect(outerShell).toContain('
') + expect(outerShell).not.toContain('contain:paint') + }) + + it('lifts both pickers above the contained shell', () => { + // The shell is a stacking context now, so it paints at z-index 0 in tree + // order — an unlayered picker would lose its drop shadow to it. + for (const picker of ['bottom-full left-0 right-0 z-20', 'bottom-full left-3 right-3 z-20']) { + expect(autocompleteMenus).toContain(picker) + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts index 13f90af696c..6cdb5dd7508 100644 --- a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts +++ b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts @@ -12,10 +12,16 @@ import type { Terminal } from '@xterm/xterm' * and the pane blinks — redrawing its whole cursor row through * `WebglRenderer._updateModel` — until the 5-minute idle timeout. * - * `cursorBlink` is the public option that tears the timer down deterministically + * `cursorBlink` is the public option that tears the timer down * (`RenderService.handleOptionsChanged` -> `WebglRenderer._updateCursorBlink`), so * "a hidden pane does not blink" stops depending on which CSS hid it. * + * Not unconditional, though: `_updateCursorBlink` resolves + * `decPrivateModes.cursorBlink ?? options.cursorBlink`, and DECSCUSR with a + * blinking style (`CSI 5 SP q`) pins that DEC mode. On a pane whose shell or agent + * has emitted one, parking the option here has no effect and the hidden pane keeps + * blinking. Making this deterministic means clearing the DEC mode too. + * * Resume restores the parked value rather than the settings value, so a pane that * was not blinking before the hide never comes back blinking. */ From d51747e4c40b600db52658daaee2cb55dc0d2d05 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:32:49 -0400 Subject: [PATCH 25/43] feat(relay): expose preloaded PostgreSQL statement statistics (#20712) --- .../src/database-postgres-timeout.test.ts | 8 +- cloud/apps/relay/src/database.ts | 2 + .../postgres-statement-stats-postgres.test.ts | 121 ++++++++++++++++++ .../relay/src/postgres-statement-stats.ts | 28 ++++ cloud/docs/orca-relay-operations.md | 15 +++ 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts create mode 100644 cloud/apps/relay/src/postgres-statement-stats.ts diff --git a/cloud/apps/relay/src/database-postgres-timeout.test.ts b/cloud/apps/relay/src/database-postgres-timeout.test.ts index f678fecc4bb..df300383c1b 100644 --- a/cloud/apps/relay/src/database-postgres-timeout.test.ts +++ b/cloud/apps/relay/src/database-postgres-timeout.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js' const fakes = vi.hoisted(() => ({ configs: [] as Array>, @@ -119,11 +120,16 @@ describe('PostgreSQL relay deadlines', () => { }) expect(ddl.length).toBeGreaterThan(0) + expect(ddl).toContain(POSTGRES_STATEMENT_STATS_MIGRATION) // Statements can open with a leading `--` rationale comment. const body = (statement: string): string => statement.replace(/^(?:\s*--[^\n]*\n)*\s*/, '') expect( - ddl.every((statement) => /^(?:CREATE|ALTER TABLE)\b/i.test(body(statement))) + ddl.every( + (statement) => + statement === POSTGRES_STATEMENT_STATS_MIGRATION || + /^(?:CREATE|ALTER TABLE)\b/i.test(body(statement)) + ) ).toBe(true) // The backfill is DML, so it stays on the deadline-bearing serving pool. expect(ddl.some((statement) => statement.includes('INSERT INTO'))).toBe(false) diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index 41ead67ea60..dee6a16e523 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -10,6 +10,7 @@ import { type PostgresPoolPressureCounts } from './postgres-pool-pressure.js' import { applyPostgresSchema } from './postgres-schema-startup.js' +import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js' import { CellInventoryHoldSamples, emptyCellInventoryHoldCounts, @@ -619,6 +620,7 @@ CREATE INDEX IF NOT EXISTS relay_audit_events_at ON relay_audit_events(at); // auto-named; the replacement is named, so both statements are no-ops on a // database the current schema created and neither can drop the other. export const POSTGRES_SCHEMA_MIGRATIONS = [ + POSTGRES_STATEMENT_STATS_MIGRATION, `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS last_considered_at BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS cohort_bucket BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_rehome_attempts diff --git a/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts b/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts new file mode 100644 index 00000000000..251e3670759 --- /dev/null +++ b/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'node:crypto' +import pg from 'pg' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { openRelayDatabase } from './database.js' +import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js' + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +describePostgres('optional PostgreSQL statement statistics', () => { + let admin: pg.Client + let preloaded: boolean + const databases: string[] = [] + const roles: string[] = [] + + beforeAll(async () => { + admin = new pg.Client({ connectionString: databaseUrl }) + await admin.connect() + const result = await admin.query<{ loaded: boolean }>( + `SELECT 'pg_stat_statements' = ANY(string_to_array( + replace(current_setting('shared_preload_libraries'), ' ', ''), ',' + )) AS loaded` + ) + preloaded = result.rows[0]!.loaded + }) + + afterAll(async () => { + for (const database of databases) await admin.query(`DROP DATABASE IF EXISTS ${database}`) + for (const role of roles) await admin.query(`DROP ROLE IF EXISTS ${role}`) + await admin.end() + }) + + async function freshDatabase(): Promise { + const name = `relay_stats_${randomUUID().replaceAll('-', '')}` + await admin.query(`CREATE DATABASE ${name}`) + databases.push(name) + const url = new URL(databaseUrl!) + url.pathname = `/${name}` + return url.toString() + } + + async function connect(url: string): Promise { + const client = new pg.Client({ connectionString: url, statement_timeout: 2_000 }) + await client.connect() + return client + } + + async function installed(client: pg.Client): Promise { + const result = await client.query<{ present: boolean }>( + `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') AS present` + ) + return result.rows[0]!.present + } + + it('exposes an existing collector idempotently, and skips servers without one', async () => { + const url = await freshDatabase() + const database = await openRelayDatabase({ databaseUrl: url, dataDir: '' }) + await database.close() + const client = await connect(url) + try { + expect(await installed(client)).toBe(preloaded) + if (preloaded) { + const before = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info') + await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) + const after = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info') + expect(after.rows).toEqual(before.rows) + await client.query('SELECT calls, wal_bytes, shared_blks_dirtied FROM public.pg_stat_statements LIMIT 1') + } else { + await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(client)).toBe(false) + } + } finally { + await client.end() + } + }) + + it.each([false, true])('tolerates missing extension privileges (read settings: %s)', async (readSettings) => { + const client = await connect(await freshDatabase()) + const role = `relay_stats_role_${randomUUID().replaceAll('-', '')}` + await admin.query(`CREATE ROLE ${role}`) + roles.push(role) + if (readSettings) await admin.query(`GRANT pg_read_all_settings TO ${role}`) + try { + await client.query(`SET ROLE ${role}`) + await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(client)).toBe(false) + expect((await client.query<{ value: number }>('SELECT 42 AS value')).rows[0]!.value).toBe(42) + } finally { + await client.end() + } + }) + + it('serializes concurrent catalog creation across directors', async () => { + const url = await freshDatabase() + const clients = await Promise.all(Array.from({ length: 5 }, async () => await connect(url))) + try { + await Promise.all(clients.map(async (client) => await client.query(POSTGRES_STATEMENT_STATS_MIGRATION))) + expect(await installed(clients[0]!)).toBe(preloaded) + } finally { + await Promise.all(clients.map(async (client) => await client.end())) + } + }) + + it('yields to an in-progress installer instead of blocking startup', async () => { + const url = await freshDatabase() + const owner = await connect(url) + const contender = await connect(url) + try { + await owner.query('BEGIN') + await owner.query(`SELECT pg_advisory_xact_lock(hashtext('orca-relay'), hashtext('statement-stats'))`) + await contender.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(contender)).toBe(false) + await owner.query('COMMIT') + await contender.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(contender)).toBe(preloaded) + } finally { + await owner.end() + await contender.end() + } + }) +}) diff --git a/cloud/apps/relay/src/postgres-statement-stats.ts b/cloud/apps/relay/src/postgres-statement-stats.ts new file mode 100644 index 00000000000..61a2fee3b75 --- /dev/null +++ b/cloud/apps/relay/src/postgres-statement-stats.ts @@ -0,0 +1,28 @@ +// Expose an already-running collector; never preload a module or require elevated runtime privileges. +export const POSTGRES_STATEMENT_STATS_MIGRATION = ` +DO $relay_statement_stats$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_settings + WHERE name = 'shared_preload_libraries' + AND 'pg_stat_statements' = ANY(string_to_array(replace(setting, ' ', ''), ',')) + ) OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements' + ) OR NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_available_extensions WHERE name = 'pg_stat_statements' + ) THEN + RETURN; + END IF; + + IF NOT pg_try_advisory_xact_lock(hashtext('orca-relay'), hashtext('statement-stats')) THEN + RETURN; + END IF; + + BEGIN + CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public; + EXCEPTION WHEN insufficient_privilege THEN + RAISE WARNING 'orca_relay_statement_stats_unavailable: insufficient privilege'; + END; +END +$relay_statement_stats$; +` diff --git a/cloud/docs/orca-relay-operations.md b/cloud/docs/orca-relay-operations.md index 4515048b29b..ea5717daa87 100644 --- a/cloud/docs/orca-relay-operations.md +++ b/cloud/docs/orca-relay-operations.md @@ -2,6 +2,21 @@ This runbook applies to the stable Cloud Run director and the production-shaped GCE cells in both environments. It does not authorize a full Terraform apply: staging and production contain unrelated drift, so inspect a saved targeted plan and its destroy count before every apply. +## PostgreSQL statement statistics + +Relay schema startup exposes `pg_stat_statements` when the server already preloads +that collector and the schema identity can install its extension. Servers without +the collector or the required privileges continue normally. Installation does not +change preload settings, reset collected counters, or require a database restart; +concurrent startups yield to one installer. An existing extension is left in place. + +For SQL incidents, inspect bounded aggregates of `calls`, `total_exec_time`, +`shared_blks_read`, `shared_blks_dirtied`, and `wal_bytes`, scoped to the relay +database and identified query IDs. Compare counter deltas over the same interval +as fleet runtime metrics; retain the statistics reset timestamp. Do not export +query text, identities, credentials, or invoke `pg_stat_statements_reset()` during +an investigation. Treat an unavailable view as missing evidence, not zero work. + The relay is automatically active for entitled signed-in desktops. There is no rollout flag, cohort, or user toggle. The emergency product kill switch is the auth plane refusing relay-token exchange; use cell drains only to move or terminate existing data-plane work. ## Safety rules From 49fba59925c91e1e874a073229ca79de4cb2579c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:45:54 -0700 Subject: [PATCH 26/43] fix(runtime): apply the tui-idle evidence ranking to mailbox delivery (#20578) fix(runtime): retry a delivery that the idle gate refused Gates delivery at the two points where each implementation commits to typing into the pane, rather than at each caller, and parks-and-re-offers a refusal so late idle evidence cannot strand a queued message. Refs #6011 --- .../orca-runtime-apply-tracked-pty-title.ts | 11 +- .../orca-runtime-deliver-pending-messages.ts | 3 +- .../orca-runtime-resolve-exit-waiters.ts | 70 +++++++ src/main/runtime/orca-runtime-runtime-id.ts | 3 + ...ntime-serialize-agent-prompt-submission.ts | 6 +- .../orca-runtime-stop-requested-pty-ids.ts | 1 + .../runtime/orca-runtime-sync-window-graph.ts | 1 + .../mailbox-pointer-delivery-contract.ts | 2 + .../orchestration/mailbox-pointer-delivery.ts | 10 + .../mailbox-pointer-stage.test.ts | 2 + .../runtime/runtime-terminal-idle-polls.ts | 17 +- .../tui-idle-delivery-and-quiescence.test.ts | 190 ++++++++++++++++++ 12 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 src/main/runtime/tui-idle-delivery-and-quiescence.test.ts diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index fc86de47451..3459c7c8b6e 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -138,7 +138,16 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper // an agent whose first live title is already idle (claude --resume at its // prompt) then shows no transition — the row would strand, which is // exactly #12536. Waiter semantics stay transition-only above. - if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) { + // Why the title change joins the edge: a name-only frame routinely lands before the + // hook's `X ready`, and it consumes the working→idle transition. The later ready title + // is an idle→idle step, so gating delivery on `prevStatus !== 'idle'` meant the + // strongest evidence this pane will ever emit never reached delivery at all. The + // waiter branch above already re-offers on that step; the gate makes a repeat harmless. + if ( + agentStatus === 'idle' && + (prevStatus !== 'idle' || !prevObservedLive || prevLeafTitle !== recordedTitle) && + this.checkDeliverySettledAndArmRecheck(leaf) + ) { this.deliverPendingMessagesForLeaf(leaf) } } diff --git a/src/main/runtime/orca-runtime-deliver-pending-messages.ts b/src/main/runtime/orca-runtime-deliver-pending-messages.ts index 8d55cddd112..55c6a8dad5e 100644 --- a/src/main/runtime/orca-runtime-deliver-pending-messages.ts +++ b/src/main/runtime/orca-runtime-deliver-pending-messages.ts @@ -111,7 +111,8 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv if ( currentLeaf?.ptyId === probedPtyId && currentLeaf.lastAgentStatus === 'idle' && - currentLeaf.lastAgentStatusObservedLive + currentLeaf.lastAgentStatusObservedLive && + this.checkDeliverySettledAndArmRecheck(currentLeaf) ) { this.deliverPendingMessages(currentLeaf, { mailboxHandle, diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index 221bb873545..ea6329db3e6 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -117,6 +117,76 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc }) } + /** + * Settled-enough-to-type check that also arms a retry when it says no. + * + * Why the retry: the wait path POLLS, so weak evidence that only becomes valid with the + * passage of time (a pane going quiet) eventually satisfies it. Delivery is edge-driven — + * a title transition, a graph sync, a new message — with no poll behind it, so a refusal + * at an edge is final unless another edge happens to arrive. A hookless Codex pane never + * emits an explicit `X ready`, so the refusal below would strand the queued message + * permanently once the pane fell quiet. One-shot timer, armed only for a leaf that + * actually refused, cleared as soon as any path delivers. + */ + protected checkDeliverySettledAndArmRecheck(leaf: { tabId: string; leafId: string }): boolean { + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + if (this.isAgentSettledForDelivery(leaf)) { + this.clearDeliveryRecheck(leafKey) + return true + } + this.armDeliveryRecheck(leafKey) + return false + } + + protected clearDeliveryRecheck(leafKey: string): void { + const timer = this.deliveryRecheckTimersByLeafKey.get(leafKey) + if (timer) { + clearTimeout(timer) + this.deliveryRecheckTimersByLeafKey.delete(leafKey) + } + } + + private armDeliveryRecheck(leafKey: string): void { + if (this.deliveryRecheckTimersByLeafKey.has(leafKey)) { + return + } + const live = this.leaves.get(leafKey) + // Why this delay: the only refusal that time alone can lift is tier 3 waiting on the + // stream to go quiet, so wake just after the window could have elapsed. A pane that is + // still producing output re-arms from its own fresher timestamp rather than spinning. + const elapsed = live?.lastOutputAt ? Date.now() - live.lastOutputAt : 0 + const delay = Math.max(TUI_IDLE_QUIESCENCE_MS - elapsed, 0) + 50 + const timer = setTimeout(() => { + this.deliveryRecheckTimersByLeafKey.delete(leafKey) + const current = this.leaves.get(leafKey) + if (!current) { + return + } + // Why the gate again here: delivery sites gate at the CALL, not inside + // deliverPendingMessagesForLeaf, so firing straight into it would hand the retry the + // very injection the gate exists to prevent. A pane that went busy again re-arms. + if (this.checkDeliverySettledAndArmRecheck(current)) { + this.deliverPendingMessagesForLeaf(current) + } + }, delay) + timer.unref?.() + this.deliveryRecheckTimersByLeafKey.set(leafKey, timer) + } + + /** + * Whether this pane is settled enough to TYPE INTO. + * + * Why the same ranking as the wait path: mailbox delivery writes the pointer plus Enter + * into the pane, so acting on a name-only `Codex` title mid-turn injects keystrokes into + * a running agent's session. That is the #6011 mis-settlement in a path with a worse + * failure mode than a racing script. Liveness stays a separate requirement — callers + * keep their own `lastAgentStatusObservedLive` checks. + */ + protected isAgentSettledForDelivery(leaf: { tabId: string; leafId: string }): boolean { + const live = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + return live ? this.isTuiIdleSatisfiedForLeaf(live) : false + } + protected isTuiIdleSatisfiedForPty(pty: RuntimePtyWorktreeRecord): boolean { return isTuiIdleSatisfied({ record: pty, diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 0806b705fdf..00ccdde9da9 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -274,6 +274,9 @@ export class OrcaRuntimeWithRuntimeId { return pty?.launchAgent ?? pty?.foregroundAgent ?? null } + /** One-shot delivery retries, keyed by leaf. See checkDeliverySettledAndArmRecheck. */ + protected deliveryRecheckTimersByLeafKey = new Map>() + protected leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 1e321bc7584..dd3afbfa23e 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -139,7 +139,11 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi leaf.lastAgentStatus = restoredStatus if (restoredStatus === 'idle') { this.resolveTuiIdleWaiters(leaf) - this.deliverPendingMessagesForLeaf(leaf) + // Why gated like every other delivery edge: a neutral-title restoration can + // reinstate `idle` from a name-only title, which is not evidence a turn ended. + if (this.checkDeliverySettledAndArmRecheck(leaf)) { + this.deliverPendingMessagesForLeaf(leaf) + } } } } diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index c7f883fcfcb..4989245fdf6 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -203,6 +203,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getLeaf: (leafKey) => this.leaves.get(leafKey), getLeafKey: (tabId, leafId) => this.getLeafKey(tabId, leafId), getLiveLeafForHandle: (handle) => this.getLiveLeafForHandle(handle).leaf, + isAgentSettledForDelivery: (leaf) => this.checkDeliverySettledAndArmRecheck(leaf), getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle), getTabTitle: (tabId) => this.tabs.get(tabId)?.title, getCliCommand: (terminalHandle) => this.getTerminalOrchestrationCliCommand(terminalHandle), diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index 3a784fcd622..e820d15130b 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -283,6 +283,7 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow this._orchestrationDb && leaf.lastAgentStatus === 'idle' && leaf.lastAgentStatusObservedLive && + this.checkDeliverySettledAndArmRecheck(leaf) && leaf.writable && (!graphWasReady || previousLeaf?.ptyId !== leaf.ptyId || diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts index a0b19b2d27a..91fcac67bd1 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts @@ -22,6 +22,8 @@ export type PointerDeliveryDependencies OrchestrationMailboxLeaf | undefined getLeafKey: (tabId: string, leafId: string) => string getLiveLeafForHandle: (handle: string) => OrchestrationMailboxLeaf + /** Whether the pane is settled enough to type the pointer plus Enter into it. */ + isAgentSettledForDelivery: (leaf: OrchestrationMailboxLeaf) => boolean getMessageWaiters: (mailboxHandle: string) => ReadonlySet | undefined getTabTitle: (tabId: string) => string | null | undefined getCliCommand: (terminalHandle: string) => OrchestrationCliCommand diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts index b16e5c474b3..b8c8ad7a0c4 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts @@ -70,6 +70,16 @@ export class OrchestrationMailboxPointerDelivery WriteSettlement) { getLeaf: () => LEAF, getLeafKey: () => 'tab-1:leaf-1', getLiveLeafForHandle: () => LEAF, + // These cases exercise staging and Enter phases, not the idle gate; the pane is settled. + isAgentSettledForDelivery: () => true, getMessageWaiters: () => undefined, getTabTitle: () => null, getCliCommand: () => 'orca' as const, diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index 4fb5c470c2f..e1be9654611 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -17,6 +17,19 @@ import { type FirstPartyAgentStatus } from './tui-idle-evidence' import type { TuiAgent } from '../../shared/tui-agent' + +/** + * Why null counts as quiet: a record with no output timestamp has produced nothing the + * RUNTIME OBSERVED since it was created. That is not the same as silence — the reachable + * case is a daemon-hosted pane whose bytes never reach the runtime, which may still be + * streaming. The trade is deliberate: "never settles" becomes "settles uncorroborated", + * the caller keeps its timeout, and delivery cannot reach this lane. Reading it as `0ms since output` + * inverted that — `0 >= quiescenceMs` is false forever, so an adopted pane that never + * emitted could not settle no matter how long the caller waited. + */ +function isQuietForQuiescence(lastOutputAt: number | null, quiescenceMs: number): boolean { + return lastOutputAt === null ? true : Date.now() - lastOutputAt >= quiescenceMs +} import type { TerminalWaiter } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' @@ -141,7 +154,7 @@ export class RuntimeTerminalIdlePolls { if ( foreground && !isShellProcess(foreground) && - (live.lastOutputAt ? Date.now() - live.lastOutputAt : 0) >= this.deps.quiescenceMs + isQuietForQuiescence(live.lastOutputAt, this.deps.quiescenceMs) ) { this.stop(entry) this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', live)) @@ -206,7 +219,7 @@ export class RuntimeTerminalIdlePolls { if ( foreground && !isShellProcess(foreground) && - (pty.lastOutputAt ? Date.now() - pty.lastOutputAt : 0) >= this.deps.quiescenceMs + isQuietForQuiescence(pty.lastOutputAt, this.deps.quiescenceMs) ) { this.stop(entry) this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) diff --git a/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts b/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts new file mode 100644 index 00000000000..02d3a62dcc9 --- /dev/null +++ b/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeTuiIdleRuntime } from './tui-idle-wait-test-harness' +import type { RuntimeSyncWindowGraph } from '../../shared/runtime-types' +import type { OrcaRuntimeService } from './orca-runtime' +import type { TuiAgent } from '../../shared/tui-agent' + +// Follow-ons to #6011. The evidence ranking that fixed the wait path did not reach two +// other consumers of the same signal: mailbox delivery, which TYPES INTO the pane, and +// the idle poll's quiescence gate, which read a missing output clock as "never quiet". + +const WORKTREE_ID = 'repo-1::/tmp/followups' +const TAB_ID = 'c1c1c1c1-c1c1-4c1c-8c1c-c1c1c1c1c1c1' +const LEAF_ID = 'c2c2c2c2-c2c2-4c2c-8c2c-c2c2c2c2c2c2' +const PTY_ID = 'pty-followups' +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const osc = (title: string) => `${ESC}]0;${title}${BEL}` +const agentStatus = (state: string, agentType: string) => + `${ESC}]9999;{"state":"${state}","agentType":"${agentType}"}${BEL}` + +const GRAPH: RuntimeSyncWindowGraph = { + tabs: [ + { tabId: TAB_ID, worktreeId: WORKTREE_ID, title: 'Agent', activeLeafId: LEAF_ID, layout: null } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: null, + title: '' + } + ] +} + +async function makeRuntime(launchAgent: TuiAgent | null, foreground = 'codex') { + const runtime = makeTuiIdleRuntime({ + repoPath: '/tmp/followups', + getForegroundProcess: async () => foreground + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, GRAPH) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: 'followups-inc', + ...(launchAgent ? { agentLaunchAuthority: { launchToken: 'tok', launchAgent } } : {}) + }) + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + return { runtime, handle: terminals[0].handle } +} + +/** Counts real delivery attempts. Spies on the delivery entry point, NOT on the gate + * under test — the gate runs for real and decides whether this is ever reached. */ +function watchDelivery(runtime: OrcaRuntimeService) { + return vi + .spyOn( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the delivery entry point is protected; the spy only needs its name and signature. + runtime as never as { deliverPendingMessagesForLeaf: (leaf: unknown) => void }, + 'deliverPendingMessagesForLeaf' + ) + .mockImplementation(() => {}) +} + +// Why fake timers: the retry fires on a real 3s quiescence window, and asserting around it +// with wall-clock sleeps made the result depend on how promptly a loaded CI runner schedules +// an interval. The clock is the thing under test, so it has to be the deterministic part. +describe('mailbox delivery honours the tui-idle evidence ranking', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('does not deliver into a pane that is only showing its agent name mid-turn', async () => { + const { runtime } = await makeRuntime('codex') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('⠋ Codex')}working\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() + + // The busy agent repaints its title to the bare product name. That reads as `idle` + // for display, but it is emitted just as often mid-turn — typing into the pane here + // injects the pointer plus Enter into a running turn. + runtime.onPtyData(PTY_ID, `${osc('Codex')}still working\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() + }) + + it('delivers once the agent states it is done', async () => { + const { runtime } = await makeRuntime('codex') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('⠋ Codex')}working\n`, Date.now()) + runtime.onPtyData(PTY_ID, `${osc('Codex ready')}done\n`, Date.now()) + expect(deliver).toHaveBeenCalled() + }) + + // Why this case exists: the wait path POLLS, so weak evidence that only becomes valid + // with time eventually satisfies it. Delivery is edge-driven with no poll behind it, so a + // refusal at an edge is final unless another edge arrives. A hookless Codex never emits an + // explicit `X ready`, so without a retry the queued message strands permanently once the + // pane falls quiet — trading a visible mis-delivery for an invisible lost message. + it('retries a refused delivery once the pane falls quiet', async () => { + const { runtime } = await makeRuntime('codex') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('\u280b Codex')}working\n`, Date.now()) + runtime.onPtyData(PTY_ID, `${osc('Codex')}output\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() + + // Output stops. No further title frame and no renderer graph sync — a daemon-hosted + // pane has nobody publishing one, so nothing re-fires an edge on its own. + await vi.advanceTimersByTimeAsync(5_000) + expect(deliver).toHaveBeenCalled() + }) + + it('does not retry into a pane that went busy again', async () => { + const { runtime } = await makeRuntime('codex') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('Codex')}output\n`, Date.now()) + // Keep the stream alive across the whole retry window. + // Deterministic streaming: one chunk every 250ms of virtual time, so the gap between + // chunks can never drift past the quiescence window the way a real interval can. + for (let tick = 0; tick < 20; tick += 1) { + runtime.onPtyData(PTY_ID, 'more output\n', Date.now()) + await vi.advanceTimersByTimeAsync(250) + } + expect(deliver).not.toHaveBeenCalled() + }) + + // Case B, the mainline path: a hooked Codex emits a name-only frame BEFORE the hook's + // `Codex ready`. The name-only frame consumes the working->idle transition, leaving the + // ready title as an idle->idle step that delivery was never offered — so the strongest + // evidence the agent ever emits could not reach it. + it('delivers when the ready title arrives after a name-only frame', async () => { + const { runtime } = await makeRuntime('codex') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('\u280b Codex')}working\n`, Date.now()) + runtime.onPtyData(PTY_ID, `${osc('Codex')}out\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(100) + runtime.onPtyData(PTY_ID, osc('Codex ready'), Date.now()) + // Promptly, on the ready title itself — not after waiting out a quiescence window. + expect(deliver).toHaveBeenCalled() + }) + + // Case C: the agent's own status stream vetoes the idle title, then reports done with no + // edge behind it. `working` stays fresh for 30 minutes, so without a re-offer the veto + // outlives the turn it described. + it('delivers when a done status lands after the idle title was vetoed', async () => { + const { runtime } = await makeRuntime('claude') + const deliver = watchDelivery(runtime) + runtime.onPtyData( + PTY_ID, + `${agentStatus('working', 'claude')}${osc('\u280b Claude')}w\n`, + Date.now() + ) + runtime.onPtyData(PTY_ID, `${osc('claude')}out\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() + + runtime.onPtyData(PTY_ID, agentStatus('done', 'claude'), Date.now()) + await vi.advanceTimersByTimeAsync(4_500) + expect(deliver).toHaveBeenCalled() + }) + + it('still delivers for an agent whose name is its only rest signal', async () => { + const { runtime } = await makeRuntime('grok', 'grok') + const deliver = watchDelivery(runtime) + runtime.onPtyData(PTY_ID, `${osc('⠋ Grok')}working\n`, Date.now()) + runtime.onPtyData(PTY_ID, `${osc('grok')}banner\n`, Date.now()) + expect(deliver).toHaveBeenCalled() + }) +}) + +describe('quiescence treats a missing output clock as quiet', () => { + it('settles a pane that has never produced output but holds a live agent process', async () => { + // No launch metadata: Orca did not start this agent, so the quiet-foreground lane is + // the only evidence available, and `lastOutputAt` is null because nothing ever arrived. + const { runtime, handle } = await makeRuntime(null, 'codex') + const leaves = + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reading the runtime's own leaf map to assert the precondition this test depends on. + (runtime as never as { leaves: Map }).leaves + expect([...leaves.values()][0].lastOutputAt).toBeNull() + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 }) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, 20_000) +}) From 4a027626e9380a9d7234f1be39d0833eb724d2e5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:46:24 -0700 Subject: [PATCH 27/43] fix(agent-session): honour the backup-recovery fence floor on surface release (#20708) * Fix surface release fence recovery floor * fix(agents): advance backup recovery floor past lost mint --- .../agent-session-backup-recovery-fence.ts | 26 ++-- .../agent-session-backup-recovery.test.ts | 114 ++++++++++++++++++ ...session-surface-release-transition.test.ts | 22 ++++ ...gent-session-surface-release-transition.ts | 3 +- .../agent-session-fence-mint-boundary.test.ts | 18 +++ src/shared/agent-session-next-fence.ts | 4 +- 6 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 src/main/runtime/agent-session-surface-release-transition.test.ts create mode 100644 src/shared/agent-session-fence-mint-boundary.test.ts diff --git a/src/main/runtime/agent-session-backup-recovery-fence.ts b/src/main/runtime/agent-session-backup-recovery-fence.ts index 88c4ad8d720..dc0fd835fa8 100644 --- a/src/main/runtime/agent-session-backup-recovery-fence.ts +++ b/src/main/runtime/agent-session-backup-recovery-fence.ts @@ -1,15 +1,14 @@ // Recovering the agent-session store from its backup, without minting a second writer. // // The backup is the previous committed generation. The commit that never landed may have granted a -// fence one higher than anything the backup records show, and `isAgentSessionFenceCurrent` compares -// with STRICT EQUALITY — so a next-fence of `recordFence + 1` would *equal* that lost grant and -// accept a writer holding it. `+2` strictly dominates it. +// fence chosen by `nextAgentSessionFence` from the backup lease, and +// `isAgentSessionFenceCurrent` compares with STRICT EQUALITY. That choice may already be above +// `runtimeFence + 1` after an earlier recovery; the new floor must strictly dominate it. // -// The bound "one lost commit can advance a session's fence by at most 1" is what makes +2 enough. -// It holds because every mint site routes through `nextAgentSessionFence` and each performs one -// transition per transaction, and because the save path aborts rather than letting the primary -// advance past a stale backup. A batching refactor would break it silently, so it is pinned by a -// test. +// The bound is one lost mint per backup generation: each mint site uses +// `nextAgentSessionFence` once per transaction, and the save path aborts rather than advancing the +// primary past a stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected +// mint is not caught. // // This records a FLOOR for the next grant and leaves the current fence alone. Rewriting the current // fence is what an earlier version did, and it corrupted exactly the records it meant to save: a @@ -24,19 +23,20 @@ // once transactions are admitted. Nulling that evidence is how you get two writers on one provider // session; the fence protects the store, not the provider session. +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionStoreState } from './agent-session-record-store-file' -/** Strictly above any fence the lost commit could have granted for that session. */ -export const AGENT_SESSION_BACKUP_RECOVERY_FENCE_MARGIN = 2 - export function raiseAgentSessionFencesAfterBackupRecovery(state: AgentSessionStoreState): void { for (const [sessionId, record] of state.records) { - const floor = record.lease.runtimeFence + AGENT_SESSION_BACKUP_RECOVERY_FENCE_MARGIN + const floor = nextAgentSessionFence(record.lease) + 1 + if (!Number.isSafeInteger(floor)) { + throw new Error('agent_session_fence_exhausted') + } state.records.set(sessionId, { ...record, lease: { ...record.lease, - minimumNextFence: Math.max(floor, record.lease.minimumNextFence ?? 0) + minimumNextFence: floor } }) } diff --git a/src/main/runtime/agent-session-backup-recovery.test.ts b/src/main/runtime/agent-session-backup-recovery.test.ts index 18d1ec00bda..0ea430774aa 100644 --- a/src/main/runtime/agent-session-backup-recovery.test.ts +++ b/src/main/runtime/agent-session-backup-recovery.test.ts @@ -187,6 +187,120 @@ describe('recovery from the committed backup', () => { expect(granted.decision === 'granted' && granted.nextFence).toBeGreaterThan(fence + 1) }) + it('does not reissue a grant after two backup fallbacks and a backup rotation', async () => { + await seedSession('session-a') + await seedSession('session-b') + const loaded = await loadAgentSessionStore(storePath, 'local') + const record = loaded.state.records.get('session-a') + if (!record) { + throw new Error('seeded session missing') + } + loaded.state.records.set('session-a', { + ...record, + lease: { + ...record.lease, + runtimeFence: 7, + claimStatus: 'released', + handoffStage: null, + reservedSpawnToken: null + } + }) + // Two commits put the prepared generation in the backup, just as normal rotation would. + await saveAgentSessionStore(storePath, loaded.state, { primaryStatus: 'validated' }) + await saveAgentSessionStore(storePath, loaded.state, { primaryStatus: 'validated' }) + const identity = { + location: record.location, + provider: record.provider, + accountHome: record.accountHome, + runtimeKind: record.lease.runtimeKind, + claimKeyId: record.lease.claimKeyId + } + + await rm(storePath, { force: true }) + const first = await openStore() + await first.retireClaimKey(`retire-${operationId()}`, NOW) + await first.reconcileOnRestart({ + probe: async () => ({ outcome: 'reservation-unused' }), + now: NOW + }) + expect(first.getRecord('session-a')?.lease).toMatchObject({ + runtimeFence: 7, + minimumNextFence: 9, + unreconciled: false + }) + const firstGrant = await first.reserveOwner({ + ...identity, + sessionId: 'session-a', + expectedFence: 7, + spawnToken: 'first-recovery', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'first-recovery' }, + now: NOW + }) + const firstFence = firstGrant.record.lease.runtimeFence + const rotated = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.get( + 'session-a' + ) + expect(rotated?.lease).toMatchObject({ runtimeFence: 7, minimumNextFence: 9 }) + + // The primary's grant is lost, but its owner may still hold that exact fence. + await rm(storePath, { force: true }) + const second = await openStore() + await second.retireClaimKey(`retire-${operationId()}`, NOW) + await second.reconcileOnRestart({ + probe: async () => ({ outcome: 'reservation-unused' }), + now: NOW + }) + const secondGrant = await second.reserveOwner({ + ...identity, + sessionId: 'session-a', + expectedFence: 7, + spawnToken: 'second-recovery', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'second-recovery' }, + now: NOW + }) + expect(secondGrant.record.lease.runtimeFence).toBeGreaterThan(firstFence) + expect((await openStore()).getRecord('session-a')?.lease.runtimeFence).toBe( + secondGrant.record.lease.runtimeFence + ) + }) + + it.each([ + [Number.MAX_SAFE_INTEGER - 2, Number.MAX_SAFE_INTEGER], + [Number.MAX_SAFE_INTEGER - 1, null] + ])('keeps the recovered floor safe at fence %i', async (runtimeFence, expectedFloor) => { + await seedSession('session-a') + await seedSession('session-b') + const backupPath = `${storePath}.bak` + const backup = JSON.parse(await readFile(backupPath, 'utf-8')) + backup.records['session-a'].lease.runtimeFence = runtimeFence + await writeFile(backupPath, JSON.stringify(backup)) + await rm(storePath, { force: true }) + + const recovered = await openStore() + if (expectedFloor === null) { + await expect(recovered.retireClaimKey(`retire-${operationId()}`, NOW)).rejects.toThrow( + 'agent_session_fence_exhausted' + ) + await expect(stat(storePath)).rejects.toMatchObject({ code: 'ENOENT' }) + const preserved = (await loadAgentSessionStore(backupPath, 'local')).state.records.get( + 'session-a' + )?.lease + expect(preserved?.runtimeFence).toBe(runtimeFence) + expect(preserved?.minimumNextFence).toBeUndefined() + } else { + await recovered.retireClaimKey(`retire-${operationId()}`, NOW) + expect(recovered.getRecord('session-a')?.lease).toMatchObject({ + runtimeFence, + minimumNextFence: expectedFloor + }) + expect((await openStore()).getRecord('session-a')?.lease.minimumNextFence).toBe(expectedFloor) + } + }) + it('leaves recovered records valid, so the next load does not quarantine them', async () => { await seedLiveSession('session-a') await seedSession('session-b') diff --git a/src/main/runtime/agent-session-surface-release-transition.test.ts b/src/main/runtime/agent-session-surface-release-transition.test.ts new file mode 100644 index 00000000000..5e671acc77f --- /dev/null +++ b/src/main/runtime/agent-session-surface-release-transition.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { releaseAgentSessionOwnerAfterSurfaceClose } from './agent-session-surface-release-transition' + +describe('agent session surface release transition', () => { + it('honours a recovery floor when releasing the owner', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'native', minimumNextFence: 9 }) + ) + + const released = releaseAgentSessionOwnerAfterSurfaceClose({ + record, + expectedFence: 7, + now: 1_800_000_001_000 + }) + + expect(released.lease.runtimeFence).toBe(9) + }) +}) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index a59d5cb5141..61da9021a5f 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -9,6 +9,7 @@ // against the dead generation land on the next one. import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import { assertFence, withLease } from './agent-session-lease-transitions' import type { AgentSessionRecordStore } from './agent-session-record-store' @@ -40,7 +41,7 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { } return withLease(record, { ...record.lease, - runtimeFence: record.lease.runtimeFence + 1, + runtimeFence: nextAgentSessionFence(record.lease), ownerProcess: null, reservedSpawnToken: null, processlessAt: null, diff --git a/src/shared/agent-session-fence-mint-boundary.test.ts b/src/shared/agent-session-fence-mint-boundary.test.ts new file mode 100644 index 00000000000..c38d17c4b23 --- /dev/null +++ b/src/shared/agent-session-fence-mint-boundary.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { resolve } from 'node:path' +import { scanSourceTree, stripComments } from './source-scan/source-tree-scan' + +const BARE_FENCE_MINT = /runtimeFence\s*:\s*[^\n]*\.runtimeFence\s*\+\s*1\b/ + +describe('agent-session fence mint boundary', () => { + it('rejects direct runtimeFence increments in shipped source', () => { + const offenders = scanSourceTree(resolve(__dirname, '..', '..', 'src')) + .filter(({ source }) => BARE_FENCE_MINT.test(stripComments(source))) + .map(({ relativePath }) => relativePath) + + expect( + offenders, + 'New bare runtimeFence mint. Route the assignment through nextAgentSessionFence(...).' + ).toEqual([]) + }) +}) diff --git a/src/shared/agent-session-next-fence.ts b/src/shared/agent-session-next-fence.ts index bf2eb9f50c7..2c05aa5004d 100644 --- a/src/shared/agent-session-next-fence.ts +++ b/src/shared/agent-session-next-fence.ts @@ -7,8 +7,8 @@ // // Recovery records the floor instead of rewriting the current fence, because `live` means a handle // proven at exactly the current fence — moving it would invalidate the very records recovery exists -// to save. Every mint site routes through here so a new transition cannot quietly reintroduce a -// bare `+ 1`; the floor is pinned by a test that drives each transition. +// to save. A source-level ratchet rejects direct `+ 1` mints, while acquisition-transition tests +// pin the one-step bound and floor; an indirected mint is not caught. import type { AgentSessionLease } from './agent-session-record' From bac96b212e85ff8f95b1a215ef2b7d9c28682154 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:52:35 -0700 Subject: [PATCH 28/43] fix(hooks): actually terminate a timed-out hook's process tree (#20576) Repairs #20559, whose termination was a no-op: `detached` is a spawn-only option and `exec` ignored it, so the shell never became a group leader. Verified against real processes. Refs #19334 --- .../hook-archive-termination-safety.test.ts | 103 ------------ .../hook-termination-real-process.test.ts | 75 +++++++++ .../hooks-archive-exit-observation.test.ts | 136 ++++++++++++---- src/main/hooks.test.ts | 81 ++++++---- src/main/hooks.ts | 150 +++++++++--------- .../windows-console-visibility-allowlist.txt | 1 - .../windows-console-visibility.test.ts | 2 +- 7 files changed, 307 insertions(+), 241 deletions(-) delete mode 100644 src/main/hook-archive-termination-safety.test.ts create mode 100644 src/main/hook-termination-real-process.test.ts diff --git a/src/main/hook-archive-termination-safety.test.ts b/src/main/hook-archive-termination-safety.test.ts deleted file mode 100644 index 7627c7e84fc..00000000000 --- a/src/main/hook-archive-termination-safety.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { Repo } from '../shared/repo-types' - -vi.mock('./effective-hook-config', () => ({ - getEffectiveHooksFromConfig: (_repo: unknown, hooks: unknown) => hooks -})) - -const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 } - -/** - * Run a hook past its deadline with `process.kill` intercepted, so the escalation's decisions are - * observed directly instead of raced against the kernel. `groupAlive` answers the signal-0 probe. - */ -async function signalsFromTimedOutHook(groupAlive: boolean): Promise { - const { runHook } = await import('./hooks') - const dir = mkdtempSync(join(tmpdir(), 'orca-hook-signals-')) - writeFileSync(join(dir, 'orca.yaml'), 'scripts:\n archive: |\n sleep 30\n') - const sent: string[] = [] - const fakeKill = (pid: number, signal?: string | number): true => { - if (signal === 0) { - if (!groupAlive) { - throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) - } - return true - } - sent.push(`${pid < 0 ? 'group' : 'child'}:${String(signal)}`) - return true - } - const spy = vi.spyOn(process, 'kill').mockImplementation(fakeKill) - try { - await runHook('archive', dir, REPO, dir, undefined, 100) - await new Promise((resolve) => setTimeout(resolve, 2_400)) - return sent - } finally { - spy.mockRestore() - rmSync(dir, { recursive: true, force: true }) - } -} - -// Why (#19334): the escalation exists for descendants that outlive the shell — a setup hook that -// backgrounds a server typically loses its leader to the first SIGTERM while the server keeps -// running. Keying the skip on the CHILD's exit would miss exactly that case; the probe asks the -// GROUP instead. The residual hazard, stated in hooks.ts: a recycled pid answers the probe too. -describe.skipIf(process.platform === 'win32')('archive hook termination', () => { - it('escalates to the group when members survive the first signal', async () => { - await expect(signalsFromTimedOutHook(true)).resolves.toEqual(['group:SIGTERM', 'group:SIGKILL']) - }, 20_000) - - it('sends nothing once the group is provably empty', async () => { - // A group that answers ESRCH has no members left to kill, and its pid may since belong to - // someone else — so neither the SIGTERM nor the escalation is delivered. - await expect(signalsFromTimedOutHook(false)).resolves.toEqual([]) - }, 20_000) -}) - -// The regression the group probe exists for, pinned directly because it cannot be reproduced -// through `runHook` with signals intercepted: with `process.kill` mocked nothing actually dies, so -// the child never reaches the exited state that a child-liveness skip would key on. -describe.skipIf(process.platform === 'win32')('terminateHookTree', () => { - const fakeChild = (exited: boolean) => ({ - pid: 4242, - exitCode: exited ? 0 : null, - signalCode: null, - kill: vi.fn() - }) - - it('signals a surviving group even though the shell leader already exited', async () => { - const { terminateHookTree } = await import('./hooks') - const sent: (string | number | undefined)[][] = [] - const recordKill = (pid: number, signal?: string | number): true => { - if (signal !== 0) { - sent.push([pid, signal]) - } - return true - } - const spy = vi.spyOn(process, 'kill').mockImplementation(recordKill) - try { - // A hook that backgrounds a server loses its leader to the first SIGTERM; the server lives on. - terminateHookTree(fakeChild(true), 'SIGKILL') - expect(sent).toEqual([[-4242, 'SIGKILL']]) - } finally { - spy.mockRestore() - } - }) - - it('sends nothing when the group answers ESRCH', async () => { - const { terminateHookTree } = await import('./hooks') - const child = fakeChild(true) - const emptyGroup = (): true => { - throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) - } - const spy = vi.spyOn(process, 'kill').mockImplementation(emptyGroup) - try { - terminateHookTree(child, 'SIGKILL') - expect(child.kill).not.toHaveBeenCalled() - } finally { - spy.mockRestore() - } - }) -}) diff --git a/src/main/hook-termination-real-process.test.ts b/src/main/hook-termination-real-process.test.ts new file mode 100644 index 00000000000..65d1b0910d1 --- /dev/null +++ b/src/main/hook-termination-real-process.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { mkdtempSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Repo } from '../shared/repo-types' + +const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 } + +const alive = (pid: number): boolean => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +/** Run a hook past its deadline and report which of its real processes survived. */ +async function survivorsAfterDeadline( + script: string +): Promise<{ shell: boolean; child: boolean; output: string; pids: number[] }> { + const { runHook } = await import('./hooks') + const dir = mkdtempSync(join(tmpdir(), 'orca-term-')) + const pidFile = join(dir, 'pids') + writeFileSync( + join(dir, 'orca.yaml'), + `scripts:\n archive: |\n${script.replace(/^/gm, ' ')}\n` + ) + let pids: number[] = [] + try { + const result = await runHook('archive', dir, REPO, dir, undefined, 400) + expect(result.success).toBe(false) + // SIGTERM lands at the deadline, SIGKILL two seconds later. + await new Promise((resolve) => setTimeout(resolve, 3_500)) + expect(existsSync(pidFile)).toBe(true) + pids = readFileSync(pidFile, 'utf8').trim().split(/\s+/).map(Number) + // Without this, a script that recorded only the shell leaves `pids[1]` undefined, `alive` + // throws, and the missing descendant reads as dead — a test that passes on nothing. + expect(pids).toHaveLength(2) + expect(pids.every((pid) => Number.isSafeInteger(pid) && pid > 0)).toBe(true) + return { shell: alive(pids[0]!), child: alive(pids[1]!), output: result.output, pids } + } finally { + for (const pid of pids) { + try { + process.kill(pid, 'SIGKILL') + } catch { + /* already gone */ + } + } + rmSync(dir, { recursive: true, force: true }) + } +} + +// NO `process.kill` mock, deliberately. The defect this file exists for — `exec` silently ignoring +// `detached`, so the shell was never a group leader and the group signal reached nothing — is +// invisible to a mocked `process.kill`, because the mock makes the signal-0 probe succeed whether +// or not a real group exists. That is the precise condition the bug turns on. +describe.skipIf(process.platform === 'win32')('hook termination against real processes', () => { + it('kills the shell and its child when the deadline expires', async () => { + const { shell, child, output } = await survivorsAfterDeadline( + 'echo "archive step 3 of 7"\nsleep 120 &\necho "$$ $!" > "$PWD/pids"\nwait' + ) + expect({ shell, child }).toEqual({ shell: false, child: false }) + // The gate reports this run as `unverifiable`; what the hook printed is the only clue why. + expect(output).toContain('archive step 3 of 7') + }, 30_000) + + it('kills a descendant that ignores SIGTERM', async () => { + // Only the group SIGKILL can end this one; a SIGTERM to the shell alone leaves it running. + const { child } = await survivorsAfterDeadline( + '(trap "" TERM; sleep 120) &\necho "$$ $!" > "$PWD/pids"\nwait' + ) + expect(child).toBe(false) + }, 30_000) +}) diff --git a/src/main/hooks-archive-exit-observation.test.ts b/src/main/hooks-archive-exit-observation.test.ts index 7773894c4c4..f924a5291cf 100644 --- a/src/main/hooks-archive-exit-observation.test.ts +++ b/src/main/hooks-archive-exit-observation.test.ts @@ -1,62 +1,130 @@ import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import type { Repo } from '../shared/repo-types' -const { execMock } = vi.hoisted(() => ({ execMock: vi.fn() })) -vi.mock('child_process', () => ({ - exec: execMock, - execFileSync: vi.fn(), - execFile: vi.fn(), - spawn: vi.fn() -})) +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) +vi.mock('child_process', () => ({ spawn: spawnMock, execFileSync: vi.fn() })) vi.mock('./effective-hook-config', () => ({ getEffectiveHooksFromConfig: () => ({ scripts: { archive: 'do-the-archive' } }) })) const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 } -const execFailure = (code: unknown): Error => Object.assign(new Error('Command failed'), { code }) - -/** Drive runHook once with the error object `exec` hands back for a given failure mode. */ -async function runArchiveWith( - error: Error | null -): Promise<{ success: boolean; exitCode?: number }> { - const { runHook } = await import('./hooks') - execMock.mockImplementationOnce((_script, _opts, cb) => { - cb(error, '', '') - return { pid: 1234, kill: vi.fn() } - }) - const outcome = await runHook('archive', '/repo/wt', REPO) - // Guard against a vacuous pass: if the mock ever stops intercepting, a real shell would run and - // this, rather than the subtle assertions below, is what fails. - expect(execMock).toHaveBeenCalled() - return outcome +/** + * A real EventEmitter, so an `error` with no listener throws exactly as Node's would — which is the + * whole point of the stream-error row below. Replays its chunks to whoever subscribes to `data`. + */ +class FakeStream extends EventEmitter { + constructor(private readonly chunks: string[]) { + super() + } + setEncoding(): void {} + override on(event: string, fn: (chunk: string) => void): this { + super.on(event, fn) + if (event === 'data') { + for (const chunk of this.chunks) { + fn(chunk) + } + } + return this + } } -// Why (#19334): an ABSENT exitCode is what the removal gate reads as `unverifiable`. The guard is -// `typeof code === 'number'`, because `exec` reports a spawn failure with a *string* code — a -// looser null-check would file ENOENT as `exited "ENOENT"`, reading a hook that never ran as one -// that reported an exit. The timeout arm of the same contract is covered against a real shell in -// hook-archive-timeout-observation.test.ts. +/** Minimal ChildProcess stand-in: runHook reads the streams and waits for close/error. */ +function fakeChild( + outcome: { code?: number | null; signal?: NodeJS.Signals | null } | Error, + stdoutChunks: string[] = [], + stdoutError?: Error +) { + const listeners: Record void)[]> = {} + const stdout = new FakeStream(stdoutChunks) + queueMicrotask(() => { + if (stdoutError) { + stdout.emit('error', stdoutError) + } + if (outcome instanceof Error) { + for (const fn of listeners.error ?? []) { + fn(outcome) + } + return + } + for (const fn of listeners.close ?? []) { + fn(outcome.code ?? null, outcome.signal ?? null) + } + }) + return { + pid: 4242, + stdout, + stderr: new FakeStream([]), + exitCode: null, + signalCode: null, + kill: () => true, + on(event: string, fn: (...args: unknown[]) => void) { + ;(listeners[event] ??= []).push(fn) + return this + } + } +} + +async function runArchiveWith( + outcome: { code?: number | null; signal?: NodeJS.Signals | null } | Error, + stdoutChunks?: string[], + stdoutError?: Error +): Promise<{ success: boolean; output: string; exitCode?: number }> { + const { runHook } = await import('./hooks') + spawnMock.mockImplementationOnce(() => fakeChild(outcome, stdoutChunks, stdoutError)) + const result = await runHook('archive', '/repo/wt', REPO) + // Guard against a vacuous pass: if the mock stops intercepting, a real shell would run. + expect(spawnMock).toHaveBeenCalled() + return result +} + +// Why (#19334): an ABSENT exitCode is what the removal gate reads as `unverifiable`. Every row here +// is a way a hook can fail to deliver one. The timeout and termination arms of the same contract +// are covered against REAL processes in hook-termination-real-process.test.ts — deliberately not +// here, because a mocked child cannot show whether a process group exists. describe('archive hook exit observation', () => { it('passes a clean run through without an exit code', async () => { - await expect(runArchiveWith(null)).resolves.toEqual({ success: true, output: '' }) + await expect(runArchiveWith({ code: 0 })).resolves.toEqual({ success: true, output: '' }) }) it.each([ ['a non-zero exit', 23], ['a shell command-not-found', 127] ])('reports %s as the observed exit it is', async (_label, code) => { - await expect(runArchiveWith(execFailure(code))).resolves.toMatchObject({ + await expect(runArchiveWith({ code })).resolves.toMatchObject({ success: false, exitCode: code }) }) + it('caps what it retains from a hook that floods stdout', async () => { + // `exec`'s 1 MiB maxBuffer is gone with `spawn`; without a cap a flooding hook grows the main + // process's heap for the whole 120 s deadline. + const megabyte = 'x'.repeat(1024 * 1024) + const result = await runArchiveWith( + { code: 0 }, + Array.from({ length: 12 }, () => megabyte) + ) + expect(result.output.length).toBeLessThan(11 * 1024 * 1024) + expect(result.output).toContain('output truncated at 10485760 bytes') + }) + + it('survives an error on the output stream', async () => { + // An `error` with no listener is an uncaught exception, and in the main process that is the + // app. `exec` never covered this either — its only `error` listener is on the child. + await expect( + runArchiveWith({ code: 0 }, ['partial'], new Error('EIO: read failed')) + ).resolves.toMatchObject({ success: true }) + }) + it.each([ - ['was killed by a signal', null], - ['never started, so the code is a string', 'ENOENT'] - ])('withholds the exit code when the hook %s', async (_label, code) => { - const result = await runArchiveWith(execFailure(code)) + ['was killed by a signal', { code: null, signal: 'SIGKILL' as const }], + // A real spawn failure carries a STRING code; the guard under test is `typeof code === + // 'number'`, so a bare Error would pass even if that guard regressed. + ['never started', Object.assign(new Error('spawn /bin/bash ENOENT'), { code: 'ENOENT' })] + ])('withholds the exit code when the hook %s', async (_label, outcome) => { + const result = await runArchiveWith(outcome) expect(result.success).toBe(false) expect(result.exitCode).toBeUndefined() }) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 12744712543..3b911de99c6 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1,6 +1,6 @@ import type * as GitRunner from './git/runner' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { makeHookTestRepo } from './hooks-test-fixtures' // Mock fs used by loadHooks @@ -13,17 +13,17 @@ vi.mock('fs', () => ({ chmodSync: vi.fn() })) -const { execMock, runWslProcessMock, gitExecFileSyncMock } = vi.hoisted(() => ({ - execMock: vi.fn(), +const { spawnMock, runWslProcessMock, gitExecFileSyncMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), runWslProcessMock: vi.fn(), gitExecFileSyncMock: vi.fn() })) vi.mock('child_process', () => ({ - exec: execMock, - execFileSync: vi.fn(), - // runner.ts imports spawn from child_process transitively. - spawn: vi.fn() + // One `spawn` for both: hooks.ts runs the script through it, and runner.ts imports it + // transitively. A second key here silently shadowed the first. + spawn: spawnMock, + execFileSync: vi.fn() })) vi.mock('./wsl/wsl-runner', () => ({ @@ -35,6 +35,35 @@ vi.mock('./git/runner', async () => ({ gitExecFileSync: gitExecFileSyncMock })) +/** Minimal ChildProcess stand-in: hooks.ts reads the streams and waits for close/error. */ +function fakeChild(exit: { code?: number | null; signal?: NodeJS.Signals | null } = { code: 0 }) { + const listeners: Record void)[]> = {} + const stream = { setEncoding: () => {}, on: () => {} } + queueMicrotask(() => { + for (const fn of listeners.close ?? []) { + fn(exit.code ?? null, exit.signal ?? null) + } + }) + return { + pid: 4242, + stdout: stream, + stderr: stream, + exitCode: null, + signalCode: null, + kill: () => true, + on(event: string, fn: (...args: unknown[]) => void) { + ;(listeners[event] ??= []).push(fn) + return this + } + } +} + +beforeEach(() => { + // Clear as well as re-arm: these assertions are order-sensitive and calls otherwise accumulate. + spawnMock.mockClear() + spawnMock.mockImplementation(() => fakeChild()) +}) + describe('runHook', () => { const makeRepo = (hookSettings?: { mode?: 'auto' | 'override' @@ -43,10 +72,7 @@ describe('runHook', () => { }) => makeHookTestRepo(hookSettings) it('uses the Windows command shell when running hooks', async () => { - execMock.mockImplementation((_script, _options, callback) => { - callback?.(null, '', '') - return {} as never - }) + spawnMock.mockImplementation(() => fakeChild()) const fs = await import('node:fs') vi.mocked(fs.existsSync).mockReturnValue(true) @@ -66,13 +92,12 @@ describe('runHook', () => { const result = await runHook('setup', 'C:\\repo\\worktree', makeRepo()) expect(result).toEqual({ success: true, output: '' }) - expect(execMock).toHaveBeenCalledWith( + expect(spawnMock).toHaveBeenCalledWith( 'echo hello', expect.objectContaining({ cwd: 'C:\\repo\\worktree', shell: 'C:\\Windows\\System32\\cmd.exe' - }), - expect.any(Function) + }) ) } finally { Object.defineProperty(process, 'platform', { @@ -91,10 +116,9 @@ describe('runHook', () => { // Why: setup scripts source conda exactly like a shell rc does, so the // orphaned CONDA_SHLVL sentinel surfaces as an opaque hook failure (#14195). let capturedEnv: Record | undefined - execMock.mockImplementation((_script, options, callback) => { + spawnMock.mockImplementation((_script, options) => { capturedEnv = (options as { env: Record }).env - callback?.(null, '', '') - return {} as never + return fakeChild() }) const fs = await import('node:fs') @@ -131,10 +155,7 @@ describe('runHook', () => { }) it('keeps bash as the hook shell on non-Windows platforms', async () => { - execMock.mockImplementation((_script, _options, callback) => { - callback?.(null, '', '') - return {} as never - }) + spawnMock.mockImplementation(() => fakeChild()) const fs = await import('node:fs') vi.mocked(fs.existsSync).mockReturnValue(true) @@ -154,7 +175,7 @@ describe('runHook', () => { const result = await runHook('setup', '/repo/worktree', makeRepo()) expect(result).toEqual({ success: true, output: '' }) - expect(execMock).toHaveBeenCalledWith( + expect(spawnMock).toHaveBeenCalledWith( 'echo hello', expect.objectContaining({ cwd: '/repo/worktree', @@ -165,8 +186,7 @@ describe('runHook', () => { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }) - }), - expect.any(Function) + }) ) } finally { Object.defineProperty(process, 'platform', { @@ -182,7 +202,8 @@ describe('runHook', () => { }) it('runs WSL hooks through runWslProcess and translates env paths to Linux', async () => { - execMock.mockReset() + spawnMock.mockReset() + spawnMock.mockImplementation(() => fakeChild()) runWslProcessMock.mockReset() runWslProcessMock.mockResolvedValue({ environmentResolved: true, @@ -228,7 +249,7 @@ describe('runHook', () => { }) }) ) - expect(execMock).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -238,7 +259,8 @@ describe('runHook', () => { }) it('runs Windows-path hooks through WSL when the project runtime targets WSL', async () => { - execMock.mockReset() + spawnMock.mockReset() + spawnMock.mockImplementation(() => fakeChild()) runWslProcessMock.mockReset() runWslProcessMock.mockResolvedValue({ environmentResolved: true, @@ -286,7 +308,7 @@ describe('runHook', () => { }) }) ) - expect(execMock).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -349,7 +371,8 @@ describe('runHook', () => { it('settles WSL hooks when wsl.exe never reports completion', async () => { // Why no fake timers: the timeout is now runProcess's own, internal to the // mocked runWslProcess -- there is nothing left in hooks.ts to advance. - execMock.mockReset() + spawnMock.mockReset() + spawnMock.mockImplementation(() => fakeChild()) runWslProcessMock.mockReset() runWslProcessMock.mockResolvedValue({ environmentResolved: true, diff --git a/src/main/hooks.ts b/src/main/hooks.ts index f9d91f38423..8ea68c8f5fa 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -14,7 +14,12 @@ import type { HookRuntimeTarget } from './hook-runtime-target' import type { OrcaHooks } from '../shared/orca-yaml-hook-types' import type { Repo } from '../shared/repo-types' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' -import { exec } from 'node:child_process' +import { spawn } from 'node:child_process' +import { + forceTerminateProcessTree, + signalProcessTree +} from '../shared/child-process/process-tree-termination' +import { createOutputSink } from '../shared/child-process/bounded-output-sink' const HOOK_TIMEOUT = 120_000 // 2 minutes @@ -53,50 +58,21 @@ function classifyHookProcessResult( const SIGTERM_GRACE_MS = 2_000 -/** Signal the hook's whole process group where the platform has one, else just the child. */ -export type TerminableChild = { - pid?: number - exitCode: number | null - signalCode: NodeJS.Signals | null - kill: (signal: NodeJS.Signals) => boolean +/** + * `exec` capped output at 1 MiB and killed the hook on overflow; `spawn` has no cap at all, and a + * hook flooding stdout for the full deadline can take the main process's heap with it. Truncation + * is reported in the output rather than as a failure — a chatty hook that exits 0 did succeed, and + * failing it for being chatty is the `exec` behaviour this is replacing. + */ +const HOOK_OUTPUT_LIMIT_BYTES = 10 * 1024 * 1024 + +function readSink(sink: ReturnType): string { + return sink.truncated() + ? `${sink.text()}\n[output truncated at ${HOOK_OUTPUT_LIMIT_BYTES} bytes]` + : sink.text() } -export function terminateHookTree(child: TerminableChild, signal: NodeJS.Signals): void { - // Why probe the GROUP and not the child: the escalation exists for descendants that outlive the - // shell. A hook that backgrounds a server typically loses its leader to the first SIGTERM while - // the server keeps running, so keying this on `child.exitCode` would skip the SIGKILL in exactly - // the case it was added for. - // - // The trade-off it does not solve: signalling by negative pid names whatever group owns that pid - // now. Once the leader is reaped its pid can be recycled, and a probe cannot tell a surviving - // descendant from a stranger that inherited the number. Killing a runaway hook is the likelier - // event and the one the deadline promises, so the group is signalled whenever it answers; the - // residual window is pid wraparound inside the two-second grace. - if (process.platform !== 'win32' && child.pid) { - try { - // Signal 0 tests for members without delivering anything: ESRCH means the group is empty. - process.kill(-child.pid, 0) - } catch { - return - } - try { - process.kill(-child.pid, signal) - return - } catch { - // Raced with the last member exiting; fall through to the direct kill. - } - } - if (child.exitCode !== null || child.signalCode !== null) { - return - } - try { - child.kill(signal) - } catch { - // Already dead. - } -} - -/** An `exec` failure: a string `code` (ENOENT) means it never started, so no exit was observed. */ +/** A spawn failure: the process never started, so no exit was ever observed. */ function hookProcessError( error: Error, stdout: string, @@ -287,8 +263,7 @@ export function runHook( // reports whatever it chose to do, so a hook that traps SIGTERM and exits 0 came back as a // PASS — a hook cut off mid-archive, indistinguishable from one that finished. Settle on the // deadline instead, and settle AT it, so a hook that traps and keeps running cannot hold a - // removal open. `exec` stays because it owns the per-platform shell invocation (`cmd.exe` - // wants `/d /s /c`, not `-c`), which is not this change's to re-derive. + // removal open. let settled = false let deadline: NodeJS.Timeout | undefined const settle = (result: HookProcessOutcome): void => { @@ -301,42 +276,71 @@ export function runHook( } resolve(result) } - const child = exec( - script, - { - cwd, - shell: getHookShell(), - // Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652). - env: promptGuardShellEnv(shellHookEnv), - // Signal the whole group on POSIX: the script is a shell, and the work is its children. - ...(process.platform === 'win32' ? {} : { detached: true }) - }, - (error, stdout, stderr) => { - if (error) { - settle(hookProcessError(error, stdout, stderr, { hookName, cwd })) - return - } - settle( - classifyHookProcessResult( - { code: 0, stdout, stderr, timedOut: false }, - { hookName, cwd, timeoutMs } - ) + // Why `spawn` and not `exec` (#19334 follow-up): `detached` is a spawn-only option — `exec` + // accepts and ignores it, so the shell never became a group leader and the group signal below + // had nothing to reach. Passing `shell` as a string keeps Node's own platform invocation, which + // is what `exec` was being kept for: `cmd.exe /d /s /c` on Windows rather than a bare `-c`. + const child = spawn(script, { + cwd, + shell: getHookShell(), + // Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652). + env: promptGuardShellEnv(shellHookEnv), + stdio: ['ignore', 'pipe', 'pipe'], + // Pinned, not left to Node's default, for the same reason `runProcess` pins it: a `cmd.exe` + // hook otherwise flashes a console window and takes focus. Pre-existing — `exec` did not set + // it either — but AGENTS.md asks for it pinned on every Windows spawn. + windowsHide: true, + // Make the shell a group leader so its children can be reached. Not on Windows, which has no + // process groups in this sense and where `detached` means a new console instead. + ...(process.platform === 'win32' ? {} : { detached: true }) + }) + const stdout = createOutputSink(HOOK_OUTPUT_LIMIT_BYTES) + const stderr = createOutputSink(HOOK_OUTPUT_LIMIT_BYTES) + child.stdout?.on('data', (chunk: Buffer | string) => stdout.write(chunk)) + child.stderr?.on('data', (chunk: Buffer | string) => stderr.write(chunk)) + // Why listeners that do nothing: an unhandled `error` on a stream is an uncaught exception, and + // in the Electron main process that is the whole app. `exec` never covered this either — its + // only `error` listener is on the child — so this is a pre-existing gap, closed the way + // `runProcess` closes it. Losing output is not worth a crash; the exit code still gets through. + for (const stream of [child.stdin, child.stdout, child.stderr]) { + stream?.on('error', () => {}) + } + child.on('error', (error) => { + settle(hookProcessError(error, readSink(stdout), readSink(stderr), { hookName, cwd })) + }) + child.on('close', (code, signal) => { + settle( + classifyHookProcessResult( + // A signalled exit reports no code, which stays `unverifiable` rather than becoming a 0. + { + code: signal ? null : code, + stdout: readSink(stdout), + stderr: readSink(stderr), + timedOut: false + }, + { hookName, cwd, timeoutMs } ) - } - ) - // Why guarded: `exec`'s callback can fire synchronously (the unit test's mock does), and arming - // a deadline on an already-settled run would later signal a process group whose pid is long - // gone — and may by then belong to something else. + ) + }) + // Why guarded: a spawn failure can settle before the deadline is armed, and arming one on a + // finished run would later signal a pid that is gone — and may by then belong to something else. if (!settled) { deadline = setTimeout(() => { settle( classifyHookProcessResult( - { code: null, stdout: '', stderr: '', timedOut: true }, + // Keep what the hook printed: it is the only clue to why the removal gate says + // `unverifiable`. + { code: null, stdout: readSink(stdout), stderr: readSink(stderr), timedOut: true }, { hookName, cwd, timeoutMs } ) ) - terminateHookTree(child, 'SIGTERM') - setTimeout(() => terminateHookTree(child, 'SIGKILL'), SIGTERM_GRACE_MS).unref?.() + // Orca's own tree terminator: POSIX process groups, `taskkill /t /f` on Windows (where a + // bare `child.kill` reaches only the shell and leaves its descendants running), and the + // recycled-pid guard that hazard needs. SIGTERM first so a well-behaved hook can clean up. + void signalProcessTree(child, 'SIGTERM') + setTimeout(() => { + void forceTerminateProcessTree(child) + }, SIGTERM_GRACE_MS).unref?.() }, timeoutMs) } }) diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index 068f9a8a96f..a59e3be4d7c 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -23,7 +23,6 @@ main/emulator/serve-sim-runtime-materializer.ts main/emulator/simctl-simulator-devices.ts main/emulator/simulator-app-visibility.ts main/external-editor-launch.ts -main/hooks.ts main/ipc/app.ts main/ipc/developer-permissions.ts main/ipc/macos-keyboard-layout-snapshot.ts diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index 69b0a99eb5b..a15f98b288b 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -34,7 +34,7 @@ const ALLOWLIST: readonly string[] = readAllowlist( * the allowlist does not bound this: a swap (one file fixed and delisted, one * new file added with its entry) satisfies both membership assertions. */ -const UNHIDDEN_SPAWNER_PIN = 65 +const UNHIDDEN_SPAWNER_PIN = 64 const CHILD_PROCESS_IMPORT = /from\s+['"](?:node:)?child_process['"]|require\(\s*['"](?:node:)?child_process['"]/ From 41e42beab42454793585003f95160113c47f2a23 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:52:45 -0700 Subject: [PATCH 29/43] fix(worktrees): safely remove prunable git-file registrations (#20617) Preserve checkout files and the named branch when removing a positively attested malformed Git-file registration. Reject file/symlink targets in deferred directory deletion. Verified exact head with 75 focused tests including actual Git malformation, preserved marker/file bytes and branch HEAD. Independent review and complete product CI passed. WSL routing is covered by unit tests; direct SSH fails safely without local recovery. Fixes #17316 --- ...malformed-worktree-registration-removal.md | 43 +++++++++++ ...worktree-deferred-removal-real-git.test.ts | 55 +++++++++++++- .../ipc/worktrees-removal-recovery.test.ts | 76 ++++++++++++++----- .../removal/execute-worktree-removal.ts | 16 ++-- .../local-worktree-removal-recovery.test.ts | 26 ++++++- src/main/local-worktree-removal-recovery.ts | 4 +- .../orca-runtime-remove-managed-worktree.ts | 26 ++++--- src/main/worktree-prunable-git-file.test.ts | 74 ++++++++++++++++++ src/main/worktree-prunable-git-file.ts | 41 ++++++++++ src/main/worktree-trash.test.ts | 22 +++++- src/main/worktree-trash.ts | 5 ++ 11 files changed, 339 insertions(+), 49 deletions(-) create mode 100644 docs/reference/malformed-worktree-registration-removal.md create mode 100644 src/main/worktree-prunable-git-file.test.ts create mode 100644 src/main/worktree-prunable-git-file.ts diff --git a/docs/reference/malformed-worktree-registration-removal.md b/docs/reference/malformed-worktree-registration-removal.md new file mode 100644 index 00000000000..34a32e58caf --- /dev/null +++ b/docs/reference/malformed-worktree-registration-removal.md @@ -0,0 +1,43 @@ +# Malformed worktree registration removal + +Git can report a linked worktree at `/.git` when its administrative +`gitdir` backlink incorrectly ends in `.git/.git`. That reproduces #17316's +validation error. The reproduction establishes the malformed registration, not +which program created it; current OMP uses ordinary `git worktree add`. + +Orca's desktop and runtime removal entry points use registration-only recovery +when Git positively marks the row prunable, the row has a named local branch and +HEAD, it is neither main nor locked, and the execution filesystem confirms the +selected `.git` path is a regular file. Missing or unknown evidence does not +permit this recovery. A symlink or directory is not a regular-file proof. + +Recovery reuses `git worktree prune` followed by a strict worktree listing that +must confirm the selected registration is gone. It does not delete the selected +file, infer a parent path for deletion, or delete the branch. Archive hooks and +checkout teardown are skipped because the selected row is not a checkout. + +Two consequences are intentional: + +- Git's prune also clears other stale, unlocked registrations in the repository; + it is not a path-scoped command. Live and locked registrations remain Git's + responsibility, and Orca verifies that the requested registration disappeared. +- The surviving checkout's `.git` file points at removed administrative metadata. + Files and its named branch are preserved; recovery removes the broken navigation + entry and does not repair or claim to restore that checkout. + +Native and WSL checks use the existing execution-filesystem accessor. WSL prune +and verification use the same selected distro. Paired runtimes run the recovery +on their owning host. Direct SSH does not enter this local recovery: its current +provider has no registration-only removal operation, and a failed remote removal +never authorizes a local fallback. + +The Git commands already exist in the 2.25-compatible cleanup path. On an older +Git that cannot positively attest this file-shaped registration as prunable, Orca +refuses this recovery. Deferred deletion independently rejects non-directory and +symlink targets, so force cannot move a `.git` file into deletion trash. + +Regression coverage is in `worktree-prunable-git-file.test.ts`, +`worktrees-removal-recovery.test.ts`, and +`worktree-deferred-removal-real-git.test.ts`. The latter reproduces the exact +malformation against the installed Git binary in a disposable repository and +checks surviving file contents, branch HEAD, and removed registration. diff --git a/src/main/git/worktree-deferred-removal-real-git.test.ts b/src/main/git/worktree-deferred-removal-real-git.test.ts index 374be4ecc0f..06b58b4319e 100644 --- a/src/main/git/worktree-deferred-removal-real-git.test.ts +++ b/src/main/git/worktree-deferred-removal-real-git.test.ts @@ -2,12 +2,14 @@ // accepts `worktree remove --force` on a path Orca just renamed away. import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { removeWorktree } from './worktree' +import { listWorktreesStrict, removeWorktree } from './worktree' +import { isPrunableGitFileWorktree } from '../worktree-prunable-git-file' +import { removeStaleLocalWorktreeRegistration } from '../local-worktree-removal-recovery' import { getWorktreeTrashRoot, isWorktreeTrashEntryName, @@ -96,6 +98,55 @@ describe('deferred worktree removal against the real Git binary', () => { expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) }) + it('does not rename a malformed registration that points at the checkout git file', async () => { + const markerPath = join(worktreePath, '.git') + const marker = await readFile(markerPath, 'utf8') + const adminPath = marker.trim().replace(/^gitdir: /, '') + await writeFile(join(adminPath, 'gitdir'), `${join(markerPath, '.git')}\n`) + await writeFile(join(worktreePath, 'untracked.txt'), 'keep this work\n') + + await expect( + removeWorktree(repoPath, markerPath, true, { deleteBranch: false }) + ).rejects.toThrow() + await whenWorktreeTrashDeletionsSettled() + + expect(await readFile(markerPath, 'utf8')).toBe(marker) + expect(await readFile(join(worktreePath, 'untracked.txt'), 'utf8')).toBe('keep this work\n') + expect(await git(['branch', '--list', 'feature'], repoPath)).toContain('feature') + expect(existsSync(getWorktreeTrashRoot(markerPath))).toBe(false) + }) + + it('prunes a proven malformed registration while retaining checkout files and its branch', async () => { + const markerPath = join(worktreePath, '.git') + const marker = await readFile(markerPath, 'utf8') + const adminPath = marker.trim().replace(/^gitdir: /, '') + await writeFile(join(adminPath, 'gitdir'), `${join(markerPath, '.git')}\n`) + await writeFile(join(worktreePath, 'untracked.txt'), 'keep this work\n') + const row = (await listWorktreesStrict(repoPath)).find((entry) => entry.path === markerPath) + expect(row).toBeDefined() + if (!row) { + throw new Error('Missing malformed registration') + } + expect(await isPrunableGitFileWorktree(row)).toBe(true) + + const result = await removeStaleLocalWorktreeRegistration({ + canonicalWorktreePath: markerPath, + repoPath, + localWorktreeGitOptions: {}, + registeredWorktree: row, + deleteBranch: true + }) + + expect(result).toEqual({ preservedBranch: { branchName: 'feature', head: row.head } }) + expect(await readFile(markerPath, 'utf8')).toBe(marker) + expect(await readFile(join(worktreePath, 'untracked.txt'), 'utf8')).toBe('keep this work\n') + expect(await git(['rev-parse', 'refs/heads/feature'], repoPath)).toBe(`${row.head}\n`) + expect((await listWorktreesStrict(repoPath)).some((entry) => entry.path === markerPath)).toBe( + false + ) + expect(existsSync(adminPath)).toBe(false) + }) + it('sweeps trash a previous run left behind', async () => { const stalePath = join( workspaceRoot, diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index e8571449321..55d47bc680a 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -402,29 +402,63 @@ describe('registerWorktreeHandlers', () => { } }) - it('retries stale Git registration cleanup after prior local filesystem recovery', async () => { - setPlatform('win32') - const missingWorktreePath = 'C:\\workspace\\already-removed' - const worktreeId = `repo-1::${missingWorktreePath}` - const registeredWorktrees = mockKnownFeatureWorktree(missingWorktreePath) - listWorktreesMock.mockResolvedValueOnce(registeredWorktrees).mockResolvedValue([]) - store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta()) + it.each([false, true])( + 'retries missing registration cleanup (prunable marker: %s)', + async (prunableMarker) => { + setPlatform('win32') + const missingWorktreePath = prunableMarker + ? 'C:\\workspace\\already-removed\\.git' + : 'C:\\workspace\\already-removed' + const worktreeId = `repo-1::${missingWorktreePath}` + const registeredWorktrees = mockKnownFeatureWorktree(missingWorktreePath).map((row) => + prunableMarker && row.path === missingWorktreePath + ? { ...row, branch: 'refs/heads/feature', prunable: true } + : row + ) + listWorktreesMock.mockResolvedValueOnce(registeredWorktrees).mockResolvedValue([]) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta()) - const result = await handlers['worktrees:remove'](null, { - worktreeId, - force: true - }) + const result = await handlers['worktrees:remove'](null, { + worktreeId, + force: true + }) - expect(result).toEqual({ - preservedBranch: { branchName: 'feature', head: 'feature' } - }) - expect(runHookMock).not.toHaveBeenCalled() - expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() - expect(removeWorktreeMock).not.toHaveBeenCalled() - expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { - cwd: '/workspace/repo' - }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(result).toEqual({ + preservedBranch: { branchName: 'feature', head: 'feature' } + }) + expect(runHookMock).not.toHaveBeenCalled() + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: '/workspace/repo' + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + } + ) + + it('cleans a prunable Git-file row before archive or checkout teardown', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-prunable-ipc-')) + const markerPath = join(root, '.git') + await writeFile(markerPath, 'gitdir: /preserved/admin\n') + const worktreeId = `repo-1::${markerPath}` + const rows = mockKnownFeatureWorktree(markerPath).map((row) => + row.path === markerPath ? { ...row, branch: 'refs/heads/feature', prunable: true } : row + ) + listWorktreesMock.mockResolvedValueOnce(rows).mockResolvedValue([]) + try { + const result = await handlers['worktrees:remove'](null, { worktreeId }) + expect(result).toEqual({ preservedBranch: { branchName: 'feature', head: 'feature' } }) + expect(runHookMock).not.toHaveBeenCalled() + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: '/workspace/repo' + }) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect((await lstat(markerPath)).isFile()).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } }) it('preserves a locked missing registration even with force', async () => { diff --git a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts index 6f0d91ffef5..443e05627ca 100644 --- a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts +++ b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts @@ -8,8 +8,9 @@ import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git- import { listWorktreesStrict as listGitWorktreesStrict } from '../../../git/worktree' import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' import { resolveWorktreeRemovalMetadata } from '../../../worktree-removal-repo-owner' +import { isPrunableGitFileWorktree } from '../../../worktree-prunable-git-file' import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety' -import { removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval } from '../../../local-worktree-removal-recovery' +import { removeStaleLocalWorktreeRegistration } from '../../../local-worktree-removal-recovery' import { runHook } from '../../../hooks' import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' import { @@ -85,13 +86,14 @@ export async function executeWorktreeRemoval( if ( !repo.connectionId && - args.force === true && - process.platform === 'win32' && - (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && - removedMeta && - (await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions)) + ((await isPrunableGitFileWorktree(registeredWorktree, localWorktreeGitOptions)) || + (args.force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions)))) ) { - const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + const removalResult = await removeStaleLocalWorktreeRegistration({ canonicalWorktreePath, repoPath: repo.path, localWorktreeGitOptions, diff --git a/src/main/local-worktree-removal-recovery.test.ts b/src/main/local-worktree-removal-recovery.test.ts index 1be0fd02cce..7d5aa442a1e 100644 --- a/src/main/local-worktree-removal-recovery.test.ts +++ b/src/main/local-worktree-removal-recovery.test.ts @@ -22,7 +22,7 @@ vi.mock('./git/worktree', () => ({ import { recoverLocalWindowsWorktreeRemoval, - removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval + removeStaleLocalWorktreeRegistration } from './local-worktree-removal-recovery' async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { @@ -306,7 +306,7 @@ describe('recoverLocalWindowsWorktreeRemoval', () => { }) }) -describe('removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval', () => { +describe('removeStaleLocalWorktreeRegistration', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() listWorktreesStrictMock.mockReset() @@ -314,9 +314,27 @@ describe('removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval', () => { listWorktreesStrictMock.mockResolvedValue([]) }) + it('prunes and strictly verifies on the selected WSL host without deleting files or branches', async () => { + const options = { wslDistro: 'Ubuntu' } + const result = await removeStaleLocalWorktreeRegistration({ + canonicalWorktreePath: '/home/dev/feature/.git', + repoPath: '/home/dev/repo', + localWorktreeGitOptions: options, + registeredWorktree: { branch: 'refs/heads/feature', head: 'abc123' }, + deleteBranch: true + }) + expect(result).toEqual({ preservedBranch: { branchName: 'feature', head: 'abc123' } }) + expect(gitExecFileAsyncMock).toHaveBeenCalledExactlyOnceWith(['worktree', 'prune'], { + cwd: '/home/dev/repo', + wslDistro: 'Ubuntu' + }) + expect(listWorktreesStrictMock).toHaveBeenCalledExactlyOnceWith('/home/dev/repo', options) + expect(removeLocalWorktreePathMock).not.toHaveBeenCalled() + }) + it('does not override a locked missing registration', async () => { await expect( - removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + removeStaleLocalWorktreeRegistration({ canonicalWorktreePath: 'C:/workspaces/feature', repoPath: 'C:/repo', localWorktreeGitOptions: {}, @@ -345,7 +363,7 @@ describe('removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval', () => { ]) await expect( - removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + removeStaleLocalWorktreeRegistration({ canonicalWorktreePath: 'C:/workspaces/feature', repoPath: 'C:/repo', localWorktreeGitOptions: {}, diff --git a/src/main/local-worktree-removal-recovery.ts b/src/main/local-worktree-removal-recovery.ts index 630dd338c20..46f1bb15c87 100644 --- a/src/main/local-worktree-removal-recovery.ts +++ b/src/main/local-worktree-removal-recovery.ts @@ -47,7 +47,7 @@ function staleRegistrationRecoveryError( error, canonicalWorktreePath, force - )} The worktree directory was removed, but Git still has stale worktree registration. Retry deletion after resolving the Git registration error.` + )} Git still has stale worktree registration. Retry deletion after resolving the Git registration error.` ) } @@ -151,7 +151,7 @@ async function isRecoverableWindowsFilesystemRemovalFailure( } } -export async function removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval( +export async function removeStaleLocalWorktreeRegistration( args: StaleLocalWorktreeRegistrationArgs ): Promise { return removeRequiredGitWorktreeRegistration(args) diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index 25a294c2a90..9cb95e5d354 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -13,13 +13,14 @@ import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { resolveWorktreeRemovalRoute } from '../worktree-removal-execution-host-route' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { listWorktreesStrict } from '../git/worktree' +import { isPrunableGitFileWorktree } from '../worktree-prunable-git-file' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' import { removeRuntimeUnregisteredWorktree } from './runtime-unregistered-worktree-removal' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { formatWorktreeRemovalError } from '../ipc/worktree-logic' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { isRuntimeWorktreePathMissing } from './runtime-worktree-filesystem' -import { removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval } from '../local-worktree-removal-recovery' +import { removeStaleLocalWorktreeRegistration } from '../local-worktree-removal-recovery' import { cleanupUnusedWorktreePushTargetRemote } from '../ipc/worktree-remote' import { removeRuntimeRegisteredRemoteWorktree } from './runtime-registered-remote-worktree-removal' import { removeRuntimeRegisteredLocalWorktree } from './runtime-registered-local-worktree-removal' @@ -146,18 +147,19 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM } if ( route.kind === 'local' && - force === true && - process.platform === 'win32' && - (isWindowsAbsolutePathLike(canonicalWorktreePath) || - !!localWorktreeGitOptions.wslDistro) && - removedMeta && - (await isRuntimeWorktreePathMissing( - route.hostId, - canonicalWorktreePath, - localWorktreeGitOptions - )) + ((await isPrunableGitFileWorktree(registeredWorktree, localWorktreeGitOptions)) || + (force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || + !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isRuntimeWorktreePathMissing( + route.hostId, + canonicalWorktreePath, + localWorktreeGitOptions + )))) ) { - const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + const removalResult = await removeStaleLocalWorktreeRegistration({ canonicalWorktreePath, repoPath: repo.path, localWorktreeGitOptions, diff --git a/src/main/worktree-prunable-git-file.test.ts b/src/main/worktree-prunable-git-file.test.ts new file mode 100644 index 00000000000..f05e5e5cb4c --- /dev/null +++ b/src/main/worktree-prunable-git-file.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { isPrunableGitFileWorktree } from './worktree-prunable-git-file' + +const { statPath, pathAccess, runtimePath } = vi.hoisted(() => ({ + statPath: vi.fn(), + pathAccess: vi.fn(), + runtimePath: vi.fn() +})) +vi.mock('./local-worktree-filesystem', () => ({ + getLocalWorktreePathAccess: pathAccess, + toLocalWorktreeRuntimePath: runtimePath +})) +const worktree: GitWorktreeInfo = { + path: '/workspaces/feature/.git', + branch: 'refs/heads/feature', + head: 'a'.repeat(40), + isMainWorktree: false, + isBare: false, + prunable: true +} +beforeEach(() => { + vi.resetAllMocks() + statPath.mockResolvedValue({ isFile: () => true }) + pathAccess.mockReturnValue({ statPath }) + runtimePath.mockImplementation((path) => path) +}) +describe('prunable Git-file registration proof', () => { + it('accepts an attested named-branch file without reading or changing its parent', async () => { + await expect(isPrunableGitFileWorktree(worktree)).resolves.toBe(true) + expect(statPath).toHaveBeenCalledExactlyOnceWith(worktree.path) + }) + it.each([ + { prunable: false }, + { prunable: undefined }, + { isMainWorktree: true }, + { isBare: true }, + { locked: true }, + { branch: '' }, + { branch: 'refs/tags/feature' }, + { branch: 'refs/heads/' }, + { head: '' }, + { path: '/workspaces/feature' } + ])('refuses insufficient registration evidence %j', async (override) => { + await expect(isPrunableGitFileWorktree({ ...worktree, ...override })).resolves.toBe(false) + expect(statPath).not.toHaveBeenCalled() + }) + it.each([{ isFile: () => false }, { type: 'directory' }, { type: 'symlink' }, {}, null])( + 'refuses non-file or unknown filesystem evidence %j', + async (entry) => { + statPath.mockResolvedValue(entry) + await expect(isPrunableGitFileWorktree(worktree)).resolves.toBe(false) + } + ) + it('leaves a vanished marker to existing missing-path recovery', async () => { + statPath.mockRejectedValue(Object.assign(new Error('marker vanished'), { code: 'ENOENT' })) + await expect(isPrunableGitFileWorktree(worktree)).resolves.toBe(false) + }) + it('does not turn host failure into cleanup permission', async () => { + statPath.mockRejectedValue(new Error('host unavailable')) + await expect(isPrunableGitFileWorktree(worktree)).rejects.toThrow('host unavailable') + }) + it('uses the selected WSL distro and translated execution path', async () => { + const options = { wslDistro: 'Ubuntu' } + runtimePath.mockReturnValue('/home/dev/feature/.git') + statPath.mockResolvedValue({ type: 'file' }) + await expect( + isPrunableGitFileWorktree({ ...worktree, path: 'C:\\workspaces\\feature\\.git' }, options) + ).resolves.toBe(true) + expect(pathAccess).toHaveBeenCalledExactlyOnceWith(options) + expect(runtimePath).toHaveBeenCalledWith('C:\\workspaces\\feature\\.git', options) + expect(statPath).toHaveBeenCalledExactlyOnceWith('/home/dev/feature/.git') + }) +}) diff --git a/src/main/worktree-prunable-git-file.ts b/src/main/worktree-prunable-git-file.ts new file mode 100644 index 00000000000..a89f3408eb3 --- /dev/null +++ b/src/main/worktree-prunable-git-file.ts @@ -0,0 +1,41 @@ +import { isENOENT } from './ipc/filesystem-path-containment' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import type { LocalWorktreeFilesystemOptions } from './local-worktree-filesystem' +import { getLocalWorktreePathAccess, toLocalWorktreeRuntimePath } from './local-worktree-filesystem' + +/** Registration cleanup must never reinterpret a malformed .git row as its parent checkout. */ +export async function isPrunableGitFileWorktree( + worktree: GitWorktreeInfo, + options: LocalWorktreeFilesystemOptions = {} +): Promise { + if ( + worktree.prunable !== true || + worktree.isMainWorktree || + worktree.isBare || + worktree.locked || + !worktree.branch.startsWith('refs/heads/') || + worktree.branch === 'refs/heads/' || + !worktree.head || + worktree.path.split(/[\\/]/).at(-1) !== '.git' + ) { + return false + } + const access = getLocalWorktreePathAccess(options) + const entry = await access + .statPath(toLocalWorktreeRuntimePath(worktree.path, options)) + .catch((error: unknown) => { + // A vanished marker leaves missing-path recovery to its existing stricter gate. + if (isENOENT(error)) { + return null + } + throw error + }) + if (!entry || typeof entry !== 'object') { + return false + } + // WSL returns the owning guest's lstat-equivalent type; native lstat rejects symlinks too. + return ( + ('type' in entry && entry.type === 'file') || + ('isFile' in entry && typeof entry.isFile === 'function' && entry.isFile() === true) + ) +} diff --git a/src/main/worktree-trash.test.ts b/src/main/worktree-trash.test.ts index 5bec9f53475..2819c789bf5 100644 --- a/src/main/worktree-trash.test.ts +++ b/src/main/worktree-trash.test.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -45,6 +45,26 @@ describe('moveWorktreeDirectoryToTrash', () => { expect(existsSync(join(trashPath!, 'node_modules', 'pkg', 'index.js'))).toBe(true) }) + it('leaves a file target untouched without creating a trash root', async () => { + const worktreePath = join(scratchDir, '.git') + await writeFile(worktreePath, 'gitdir: /preserved/admin\n') + + expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() + expect(await readFile(worktreePath, 'utf8')).toBe('gitdir: /preserved/admin\n') + expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) + }) + + it('leaves a directory symlink and its target untouched', async () => { + const target = join(scratchDir, 'target') + const worktreePath = join(scratchDir, 'link') + await createWorktreeDirectory(target) + await symlink(target, worktreePath, process.platform === 'win32' ? 'junction' : 'dir') + + expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() + expect(existsSync(join(worktreePath, 'node_modules', 'pkg', 'index.js'))).toBe(true) + expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) + }) + it('generates sweepable, collision-free entry names', async () => { const first = await moveWorktreeDirectoryToTrash(await seededWorktree('one')) const second = await moveWorktreeDirectoryToTrash(await seededWorktree('two')) diff --git a/src/main/worktree-trash.ts b/src/main/worktree-trash.ts index cec17bf87cd..cb4d3d52e03 100644 --- a/src/main/worktree-trash.ts +++ b/src/main/worktree-trash.ts @@ -40,6 +40,11 @@ export async function moveWorktreeDirectoryToTrash( const trashRoot = getWorktreeTrashRoot(worktreePath) const trashPath = join(trashRoot, `wt-${Date.now()}-${randomBytes(4).toString('hex')}`) try { + // A malformed Git registration can name the checkout's .git file. + const worktreeStat = await lstat(worktreePath) + if (!worktreeStat.isDirectory() || worktreeStat.isSymbolicLink()) { + return undefined + } await mkdir(trashRoot, { recursive: true }) const trashRootStat = await lstat(trashRoot) if (!trashRootStat.isDirectory() || trashRootStat.isSymbolicLink()) { From dd85e5fc81683133694f27d1ac6b1a5aae8a0795 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:54:53 -0700 Subject: [PATCH 30/43] fix: keep OMP terminals when folder workspaces become Git repos (#20653) Preserve the original folder locator through Git upgrade and subsequent listing, persistence, and removal decisions after proving it still names the same checkout. Independently reviewed with 60 focused persistence/listing/removal tests and six native Windows real-Git/NTFS cases covering case/slashes, junction retention and retargeting, remote-host isolation and unrelated checkout preservation. Prior source-connected native OMP proof confirms process survival. Full PR CI passed; no rebuilt full-app after-proof claimed. --- src/main/folder-upgrade-worktree-path.test.ts | 73 +++++++++++++ src/main/folder-upgrade-worktree-path.ts | 41 +++++++ src/main/ipc/folder-repo-git-upgrade.test.ts | 6 +- src/main/ipc/folder-repo-git-upgrade.ts | 8 +- .../listing/ssh-worktree-fallback.ts | 5 +- .../repo-lifecycle-operations.ts | 1 + .../tracking-repos/repo-hydration.ts | 4 + .../tracking-repos/repo-update-operations.ts | 8 ++ src/main/repo-worktrees.test.ts | 40 ++++++- src/main/repo-worktrees.ts | 10 +- .../repo-worktree-row-resolution.test.ts | 41 +++++++ .../runtime/repo-worktree-row-resolution.ts | 3 +- src/shared/repo-types.ts | 2 + ...-upgrade-identity-persistence.unit.test.ts | 101 ++++++++++++++++++ 14 files changed, 332 insertions(+), 11 deletions(-) create mode 100644 src/main/folder-upgrade-worktree-path.test.ts create mode 100644 src/main/folder-upgrade-worktree-path.ts create mode 100644 tests/e2e/folder-upgrade-identity-persistence.unit.test.ts diff --git a/src/main/folder-upgrade-worktree-path.test.ts b/src/main/folder-upgrade-worktree-path.test.ts new file mode 100644 index 00000000000..37d28a32837 --- /dev/null +++ b/src/main/folder-upgrade-worktree-path.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Repo } from '../shared/repo-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { preserveFolderUpgradeWorktreePath } from './folder-upgrade-worktree-path' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +const repo: Repo = { + id: 'folder', + path: 'C:\\projects\\draft', + displayName: 'draft', + badgeColor: 'blue', + addedAt: 0, + kind: 'git', + folderUpgradeGitRootPath: 'C:/projects/draft' +} +function row(path: string): GitWorktreeInfo { + return { path, branch: 'draft', head: 'abc', isBare: false, isMainWorktree: false } +} + +describe('upgraded folder path projection', () => { + it('leaves existing Git repos and unrelated linked checkouts untouched', () => { + const rows = [row('C:/projects/draft'), row('C:/projects/other')] + expect( + preserveFolderUpgradeWorktreePath({ ...repo, folderUpgradeGitRootPath: undefined }, rows) + ).toBe(rows) + expect(preserveFolderUpgradeWorktreePath(repo, rows)).toEqual([ + { ...rows[0], path: repo.path }, + rows[1] + ]) + expect(rows[0].path).toBe('C:/projects/draft') + }) + + it('is idempotent and does not publish both Windows separator spellings', () => { + const rows = [row(repo.path), row('c:/projects/draft')] + const projected = preserveFolderUpgradeWorktreePath(repo, rows) + expect(projected).toEqual([row(repo.path)]) + expect(preserveFolderUpgradeWorktreePath(repo, projected)).toEqual(projected) + }) + + it('does not equate case-distinct POSIX workspaces', () => { + const owner = { ...repo, path: '/project/draft', folderUpgradeGitRootPath: '/project/draft' } + const rows = [row('/project/draft'), row('/project/Draft')] + expect(preserveFolderUpgradeWorktreePath(owner, rows)).toEqual(rows) + }) + + it('revalidates a symlink locally and refuses to inspect a remote symlink', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-folder-upgrade-path-'))) + roots.push(root) + const target = join(root, 'target') + const other = join(root, 'other') + const alias = join(root, 'alias') + mkdirSync(target) + mkdirSync(other) + symlinkSync(target, alias, 'junction') + const owner = { ...repo, path: alias, folderUpgradeGitRootPath: target } + const rows = [row(target)] + expect(preserveFolderUpgradeWorktreePath(owner, rows)).toEqual([row(alias)]) + expect( + preserveFolderUpgradeWorktreePath({ ...owner, executionHostId: 'ssh:builder' }, rows) + ).toBe(rows) + rmSync(alias) + symlinkSync(other, alias, 'junction') + expect(preserveFolderUpgradeWorktreePath(owner, rows)).toBe(rows) + }) +}) diff --git a/src/main/folder-upgrade-worktree-path.ts b/src/main/folder-upgrade-worktree-path.ts new file mode 100644 index 00000000000..eb70bd65031 --- /dev/null +++ b/src/main/folder-upgrade-worktree-path.ts @@ -0,0 +1,41 @@ +import { realpathSync } from 'node:fs' +import type { Repo } from '../shared/repo-types' +import type { GitWorktreeInfo } from '../shared/worktree/types' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { areWorktreePathsEqual, dedupeWorktreesByPath } from './ipc/worktree-path-comparison' + +function stillNamesRegisteredCheckout(repo: Repo, gitRoot: string): boolean { + if (areWorktreePathsEqual(repo.path, gitRoot)) { + return true + } + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { + return false + } + try { + // A symlink may have been retargeted since the upgrade. + return areWorktreePathsEqual(realpathSync(repo.path), realpathSync(gitRoot)) + } catch { + return false + } +} + +export function preserveFolderUpgradeWorktreePath( + repo: Repo, + worktrees: GitWorktreeInfo[] +): GitWorktreeInfo[] { + const gitRoot = repo.folderUpgradeGitRootPath + if ( + repo.kind !== 'git' || + typeof gitRoot !== 'string' || + !gitRoot || + !stillNamesRegisteredCheckout(repo, gitRoot) + ) { + return worktrees + } + // Apply after raw Git caches: this repo's locator must not leak into another registration. + return dedupeWorktreesByPath( + worktrees.map((worktree) => + areWorktreePathsEqual(worktree.path, gitRoot) ? { ...worktree, path: repo.path } : worktree + ) + ) +} diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index d5de650f7ce..1cd92144660 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -183,6 +183,7 @@ describe('folder repo git upgrade watch', () => { expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { kind: 'git', + folderUpgradeGitRootPath: repoPath.replaceAll('\\', '/'), externalWorktreeVisibility: 'hide' }) expect(prepareLocalWorktreeRootForRepo).toHaveBeenCalledTimes(1) @@ -206,7 +207,10 @@ describe('folder repo git upgrade watch', () => { }) await tick() - expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { kind: 'git' }) + expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { + kind: 'git', + folderUpgradeGitRootPath: join(root, 'symlinked-project').replaceAll('\\', '/') + }) }) it('refuses a project that has folder workspaces the git listing would drop', async () => { diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index ede90ca6ab9..e393ae56158 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -101,7 +101,9 @@ function resolveRealPath(pathValue: string): string { * the path the user picked; when a symlinked parent makes those differ, the root reads * as an *external* worktree, and hiding those would hide the project's only workspace. */ -function resolveUpgrade(repoPath: string): { externalWorktreeVisibility?: 'hide' } | null { +function resolveUpgrade( + repoPath: string +): { folderUpgradeGitRootPath: string; externalWorktreeVisibility?: 'hide' } | null { if (!isGitRepo(repoPath)) { return null } @@ -110,8 +112,8 @@ function resolveUpgrade(repoPath: string): { externalWorktreeVisibility?: 'hide' return null } return normalizeRuntimePathForComparison(gitRoot) === normalizeRuntimePathForComparison(repoPath) - ? { externalWorktreeVisibility: 'hide' } - : {} + ? { folderUpgradeGitRootPath: gitRoot, externalWorktreeVisibility: 'hide' } + : { folderUpgradeGitRootPath: gitRoot } } type UpgradeResult = 'upgraded' | 'blocked' | 'rejected' diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts index ecf8ab3abc1..594b75cc5da 100644 --- a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -1,3 +1,4 @@ +import { preserveFolderUpgradeWorktreePath } from '../../../folder-upgrade-worktree-path' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import { parseWorktreeId, areWorktreePathsEqual, mergeWorktree } from '../../worktree-logic' import { @@ -144,7 +145,9 @@ export function buildDetectedGitWorktrees( const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) // Why: a prunable registration has no working directory (issue #8389); only this listing omits it — cleanup flows list separately. const liveWorktrees = dedupeWorktreesByPath( - gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) + preserveFolderUpgradeWorktreePath(repo, gitWorktrees).filter( + (gitWorktree) => !gitWorktree.prunable + ) ) const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( [repo.path, ...liveWorktrees.map((worktree) => worktree.path)], diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index e5c75c1ff01..ec1df02d714 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -172,6 +172,7 @@ export class RepoLifecycleOperations { | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' + | 'folderUpgradeGitRootPath' | 'executionHostId' | 'symlinkPaths' | 'issueSourcePreference' diff --git a/src/main/persistence/tracking-repos/repo-hydration.ts b/src/main/persistence/tracking-repos/repo-hydration.ts index 10728f55b67..3c5a455419c 100644 --- a/src/main/persistence/tracking-repos/repo-hydration.ts +++ b/src/main/persistence/tracking-repos/repo-hydration.ts @@ -28,6 +28,7 @@ export function repoGitUsernameCacheKey( export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap): Repo { const { + folderUpgradeGitRootPath, repoIcon: rawRepoIcon, upstream: rawUpstream, gitRemoteIdentity: rawGitRemoteIdentity, @@ -57,6 +58,9 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap ({ listWorktreeGraph: listWorktreeGraphMock, listWorktrees: listWorktreesMock, - listWorktreesStrict: listWorktreesStrictMock + listWorktreesStrict: listWorktreesStrictMock, + listWorktreesSharedStrictAllowingTrueEmpty: listWorktreesStrictMock })) import { @@ -17,7 +18,8 @@ import { isRepoRoot, listLocalRepoWorktreesStrict, listRepoWorktreeGraph, - listRepoWorktrees + listRepoWorktrees, + listRepoWorktreesForDetectedScan } from './repo-worktrees' import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch' import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability' @@ -270,3 +272,37 @@ describe('repo-worktrees', () => { expect(isRepoRoot(repos, String.raw`c:\repo`)).toBe(true) }) }) + +it('keeps an upgraded linked folder locator in every local listing, including restart hydration', async () => { + const repo = { + id: 'folder', + path: 'C:\\projects\\draft', + displayName: 'draft', + badgeColor: 'blue', + addedAt: 0, + kind: 'git' as const, + folderUpgradeGitRootPath: 'C:/projects/draft' + } + const raw = [ + { path: 'C:/projects/main', head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { + path: 'C:/projects/draft', + head: 'def', + branch: 'draft', + isBare: false, + isMainWorktree: false + } + ] + listWorktreesMock.mockResolvedValue(raw) + listWorktreeGraphMock.mockResolvedValue(raw) + listWorktreesStrictMock.mockResolvedValue(raw) + for (const list of [ + listRepoWorktrees, + listRepoWorktreesForDetectedScan, + listRepoWorktreeGraph, + listLocalRepoWorktreesStrict + ]) { + expect(await list(repo)).toEqual([raw[0], { ...raw[1], path: repo.path }]) + } + expect(raw[1].path).toBe('C:/projects/draft') +}) diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index f5d67523286..02495c9ff5c 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,3 +1,4 @@ +import { preserveFolderUpgradeWorktreePath } from './folder-upgrade-worktree-path' import type { Repo } from '../shared/repo-types' import type { GitWorktreeInfo } from '../shared/worktree/types' import { @@ -93,9 +94,10 @@ async function listRoutedRepoWorktrees( } return await route.provider.listWorktrees(repo.path) } - return hasLocalRepoWorktreeListOptions(options) + const worktrees = hasLocalRepoWorktreeListOptions(options) ? await listLocal(repo.path, options) : await listLocal(repo.path) + return preserveFolderUpgradeWorktreePath(repo, worktrees) } /** @@ -122,9 +124,10 @@ export async function listRepoWorktreeGraph( if (route.kind === 'ssh') { return route.provider ? await route.provider.listWorktrees(repo.path) : [] } - return hasLocalRepoWorktreeListOptions(options) + const worktrees = hasLocalRepoWorktreeListOptions(options) ? await listWorktreeGraph(repo.path, options) : await listWorktreeGraph(repo.path) + return preserveFolderUpgradeWorktreePath(repo, worktrees) } export async function listLocalRepoWorktreesStrict( @@ -137,7 +140,8 @@ export async function listLocalRepoWorktreesStrict( if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] } - return hasLocalRepoWorktreeListOptions(options) + const worktrees = hasLocalRepoWorktreeListOptions(options) ? await listWorktreesStrict(repo.path, options) : await listWorktreesStrict(repo.path) + return preserveFolderUpgradeWorktreePath(repo, worktrees) } diff --git a/src/main/runtime/repo-worktree-row-resolution.test.ts b/src/main/runtime/repo-worktree-row-resolution.test.ts index 32111ef2a53..3576b89fe88 100644 --- a/src/main/runtime/repo-worktree-row-resolution.test.ts +++ b/src/main/runtime/repo-worktree-row-resolution.test.ts @@ -4,6 +4,8 @@ import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { Store } from '../persistence' +import { mergeWorktreeMetaForWrite } from '../persistence/loading-store/worktree-meta-write-normalization' +import { buildDetectedGitWorktrees } from '../ipc/worktrees/listing/ssh-worktree-fallback' import { listStoredWorktreeRowsForRepo, resolveRepoWorktreeRows, @@ -350,3 +352,42 @@ describe('scoped worktree id resolution across path spellings (#16243)', () => { expect(deps.scanRepo).not.toHaveBeenCalled() }) }) + +describe('folder-to-Git checkout identity', () => { + it.each([ + ['C:\\projects\\draft', 'C:/projects/draft'], + ['C:\\projects\\draft', 'c:/projects/draft'] + ])( + 'preserves the live folder locator %s in desktop and runtime listings', + async (folderPath, gitPath) => { + const owner = { + ...repo('folder', folderPath), + kind: 'git' as const, + folderUpgradeGitRootPath: gitPath + } + const deps = createDeps([owner]) + const oldId = `folder::${folderPath}` + const metadata = mergeWorktreeMetaForWrite(undefined, { + hostId: 'local', + instanceId: 'existing-omp', + comment: 'keep me' + }) + deps.metaById[oldId] = metadata + Object.assign(deps.store, { getProjectHostSetups: () => [] }) + deps.scanRepo.mockResolvedValue({ ok: true, worktrees: [gitWorktree(gitPath)] }) + + const detected = buildDetectedGitWorktrees(deps.store, owner, [gitWorktree(gitPath)]) + const rows = await resolveRepoWorktreeRows(deps, owner, deps.metaById, new Map()) + for (const result of [detected, rows]) { + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + id: oldId, + path: folderPath, + instanceId: 'existing-omp', + comment: 'keep me' + }) + } + expect(Object.keys(deps.metaById)).toEqual([oldId]) + } + ) +}) diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index e5e88607629..6fca5a5f3b2 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -1,3 +1,4 @@ +import { preserveFolderUpgradeWorktreePath } from '../folder-upgrade-worktree-path' import { splitWorktreeId, splitWorktreeIdForFilesystem, @@ -132,7 +133,7 @@ export async function resolveRepoWorktreeRows( RESOLVED_WORKTREE_REPO_TIMEOUT_MS, null )) ?? { ok: false, worktrees: listStoredWorktreeRowsForRepo(store, repo, repoOwnerCount) } - const gitWorktrees = scan.worktrees + const gitWorktrees = preserveFolderUpgradeWorktreePath(repo, scan.worktrees) if (scan.ok) { pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) } diff --git a/src/shared/repo-types.ts b/src/shared/repo-types.ts index a6980b45d54..9e3e53cf7a3 100644 --- a/src/shared/repo-types.ts +++ b/src/shared/repo-types.ts @@ -52,6 +52,8 @@ export type Repo = { upstream?: GitHubRepositoryIdentity | null addedAt: number kind?: RepoKind + /** Git root proven during folder upgrade; keeps the original checkout locator stable. */ + folderUpgradeGitRootPath?: string gitUsername?: string worktreeBaseRef?: string /** Optional repo-scoped workspace root override. Relative paths resolve from `path`. */ diff --git a/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts b/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts new file mode 100644 index 00000000000..417133b7c43 --- /dev/null +++ b/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts @@ -0,0 +1,101 @@ +import { getDefaultWorkspaceSession } from '../../src/shared/constants' +import type { AppState } from '../../src/renderer/src/store/types' +import { getRemovedWorktreeIdsAfterAuthoritativeScan } from '../../src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership' +import { mergeWorktree } from '../../src/main/ipc/worktree-metadata-merge' +import { createFolderWorktree } from '../../src/main/repo-worktrees' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createStore, + makeRepo, + makeTerminalTab, + testState +} from '../../src/main/persistence-test-harness' +import { buildDetectedGitWorktrees } from '../../src/main/ipc/worktrees/listing/ssh-worktree-fallback' +import { resolveRepoWorktreeRows } from '../../src/main/runtime/repo-worktree-row-resolution' + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-folder-upgrade-store-')) +}) +afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) +}) + +it('retains the folder instance and metadata through upgrade, listing, and Store reload', async () => { + const store = createStore() + const owner = makeRepo({ id: 'folder', path: 'C:\\projects\\draft', kind: 'folder' }) + store.addRepo(owner) + const id = `folder::${owner.path}` + const before = store.setWorktreeMetaForHost(id, 'local', { comment: 'ongoing OMP work' }) + store.setWorktreeMetaForHost(id, 'ssh:builder', { comment: 'other host' }) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + activeRepoId: owner.id, + activeWorktreeId: id, + activeTabId: 'omp-tab', + tabsByWorktree: { [id]: [makeTerminalTab({ id: 'omp-tab', worktreeId: id })] } + }) + store.updateRepo(owner.id, { kind: 'git', folderUpgradeGitRootPath: 'C:/projects/draft' }) + store.flush() + const reloaded = createStore() + const repo = reloaded.getRepo(owner.id) + expect(repo?.folderUpgradeGitRootPath).toBe('C:/projects/draft') + if (!repo) { + throw new Error('registered repo missing') + } + const worktrees = [ + { path: 'C:/projects/draft', branch: 'main', head: 'abc', isMainWorktree: true, isBare: false } + ] + const detected = buildDetectedGitWorktrees(reloaded, repo, worktrees) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The purge reader only reads these catalog fields when session hydration is complete. + const state = { + repos: [owner], + worktreesByRepo: { [owner.id]: [mergeWorktree(owner.id, createFolderWorktree(owner), before)] }, + detectedWorktreesByRepo: {}, + hasHydratedWorktreePurge: true + } as unknown as AppState + expect( + getRemovedWorktreeIdsAfterAuthoritativeScan( + state, + owner.id, + { repoId: owner.id, authoritative: true, source: 'git', worktrees: detected }, + 'local' + ) + ).toEqual([]) + expect(reloaded.getWorkspaceSession()).toMatchObject({ + activeWorktreeId: id, + activeTabId: 'omp-tab', + tabsByWorktree: { [id]: [{ id: 'omp-tab', worktreeId: id }] } + }) + const runtime = await resolveRepoWorktreeRows( + { + store: reloaded, + scanRepo: async () => ({ ok: true, worktrees }), + listFolderWorkspaces: () => [] + }, + repo, + reloaded.getAllWorktreeMeta(), + new Map() + ) + for (const rows of [detected, runtime]) { + expect(rows[0]).toMatchObject({ + id, + instanceId: before.instanceId, + comment: 'ongoing OMP work', + hostId: 'local' + }) + } + expect(reloaded.getWorktreeMetaForHost(id, 'ssh:builder')?.comment).toBe('other host') + reloaded.flush() + expect(createStore().getWorktreeMetaForHost(id, 'local')?.instanceId).toBe(before.instanceId) +}) + +it('drops upgrade path evidence when execution ownership changes', () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'folder', path: 'C:\\projects\\draft', kind: 'git' })) + store.updateRepo('folder', { folderUpgradeGitRootPath: 'C:/projects/draft' }) + store.updateRepo('folder', { executionHostId: 'ssh:builder' }) + expect(store.getRepo('folder')?.folderUpgradeGitRootPath).toBeUndefined() +}) From f55b7ba680aae12364ace246475ba6ff024a6b4d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:59:03 -0700 Subject: [PATCH 31/43] fix(native-chat): cancel pending prompts precisely (#20601) * fix(native-chat): hide activity while awaiting input * fix(native-chat): keep approval turns cancellable * test(native-chat): satisfy split PR quality gate * fix(native-chat): catalog approval cancellation label * fix(native-chat): include approval cancellation runtime label * fix(codex): settle prompts when cancelled turns complete * fix(codex): settle prompt registry fallbacks * test(native-chat): cover pending interaction fallbacks * test(native-chat): split prompt state coverage * test(native-chat): keep prompt state isolated * fix(native-chat): bound prompt turn backfill * refactor(codex): centralize prompt registry bounds * fix(native-chat): cancel pending prompts precisely * fix(native-chat): consolidate capability imports * fix(native-chat): harden precise prompt cancellation * fix claude cancellation teardown races * retry claude prompt lifecycle admission * bound claude prompt cancellation retry work * fix(codex): bound prompt turn identity on registration * fix(native-chat): route rejected late dispatch settlements * fix(codex): retain exact cancellable prompt turn ids --------- Co-authored-by: Merge Sim --- .../src/session/MobileNativeChatOverlay.tsx | 1 + .../MobileNativeChatPermission.test.ts | 22 +- .../session/MobileNativeChatPermission.tsx | 24 +- .../session/MobileNativeChatPromptCard.tsx | 4 + .../session/MobileNativeChatQuestion.test.tsx | 26 +- .../src/session/MobileNativeChatQuestion.tsx | 26 +- mobile/src/session/MobileNativeChatView.tsx | 4 + .../mobile-native-chat-controller-contract.ts | 4 + .../session/mobile-native-chat-permission.ts | 2 + .../session/mobile-native-chat-question.ts | 2 + .../mobile-session-route-parity.test.ts | 12 +- .../mobile-structured-agent-prompts.ts | 5 +- .../mobile-structured-agent-session-cancel.ts | 78 ++ .../mobile-structured-grouped-question.ts | 4 +- .../use-mobile-native-chat-controller.test.ts | 6 + .../use-mobile-native-chat-controller.ts | 11 + .../use-mobile-native-chat-session-lane.ts | 3 + ...se-mobile-session-feedback-capabilities.ts | 7 + ...se-mobile-session-native-chat-dictation.ts | 2 + .../use-mobile-session-tab-reconciliation.ts | 11 +- ...tured-agent-session-prompt-cancel.test.tsx | 221 +++++ .../use-mobile-structured-agent-session.ts | 79 +- src/main/claude/claude-prompt-registry.ts | 224 +++++ .../claude-structured-control-actions.test.ts | 134 ++- .../claude-structured-control-actions.ts | 35 +- src/main/claude/claude-structured-dispatch.ts | 47 +- .../claude-structured-inbound-control.ts | 6 +- ...de-structured-journal-prompt-retry.test.ts | 178 ++++ .../claude-structured-journal-prompts.ts | 191 +++++ ...ude-structured-journal-translation.test.ts | 53 +- .../claude-structured-journal-translation.ts | 20 +- .../claude-structured-prompt-items.test.ts | 42 + .../claude/claude-structured-prompt-items.ts | 5 +- ...claude-structured-prompt-ownership.test.ts | 766 ++++++++++++++++++ .../claude-structured-prompt-ownership.ts | 143 ++++ .../claude-structured-prompt-replies.ts | 168 +--- .../claude-structured-session-acquisition.ts | 1 + .../claude-structured-session-adapter.test.ts | 12 +- .../claude-structured-session-adapter.ts | 51 +- .../claude/claude-structured-session-state.ts | 15 +- .../codex/codex-prompt-registry-bounds.ts | 5 +- src/main/codex/codex-prompt-registry.ts | 278 +++++++ .../codex-structured-journal-contracts.ts | 1 + .../codex/codex-structured-journal-prompts.ts | 37 +- .../codex-structured-journal-translation.ts | 1 + .../codex-structured-prompt-ownership.test.ts | 680 ++++++++++++++++ .../codex-structured-prompt-ownership.ts | 103 +++ .../codex-structured-prompt-replies.test.ts | 33 +- .../codex/codex-structured-prompt-replies.ts | 274 +------ ...ructured-session-adapter-lifecycle.test.ts | 3 +- .../codex-structured-session-adapter.test.ts | 27 +- .../codex/codex-structured-session-adapter.ts | 39 +- .../codex-structured-turn-cancellation.ts | 86 +- .../journal-prompt-body-bounds.ts | 34 +- .../structured-agent-session-adapter.ts | 13 +- ...ured-agent-session-attach-orchestration.ts | 4 +- ...tured-agent-session-grouped-prompt.test.ts | 81 +- ...structured-agent-session-host-mutations.ts | 27 +- ...uctured-agent-session-host-test-harness.ts | 47 +- .../structured-agent-session-host.test.ts | 120 ++- .../structured-agent-session-host.ts | 1 + ...ured-agent-session-late-settlement.test.ts | 22 + ...ctured-agent-session-mutation-admission.ts | 2 + ...structured-agent-session-mutation-plans.ts | 7 +- ...ctured-agent-session-prompt-cancel.test.ts | 212 +++++ .../structured-agent-session-prompt-state.ts | 59 ++ .../structured-agent-session-rewind.ts | 1 + ...red-agent-session-send-idempotency.test.ts | 2 + ...d-agent-session-stale-turn-verdict.test.ts | 81 +- ...ctured-agent-session-stale-turn-verdict.ts | 32 +- .../structured-agent-session-turns-prompt.ts | 97 +-- .../structured-agent-session-turns.test.ts | 4 + .../structured-agent-session-turns.ts | 19 +- .../structured-conversation-command.ts | 1 + .../methods/structured-agent-session.test.ts | 24 + .../structured-agent-session-runtime.ts | 9 +- .../NativeChatStructuredSession.test.tsx | 10 +- .../NativeChatStructuredSession.tsx | 20 +- ...tured-agent-session-prompt-cancel.test.tsx | 137 ++++ .../use-structured-agent-session.ts | 18 +- .../structured-agent-session-client.test.ts | 49 +- .../structured-agent-session-client.ts | 29 +- src/shared/agent-session-wire.ts | 2 + src/shared/protocol-version.ts | 5 + .../structured-agent-session-params.ts | 21 +- ...ctured-agent-session-dispatch-rejection.ts | 6 +- src/shared/structured-agent-session-outbox.ts | 7 + ...red-agent-session-send-disposition.test.ts | 11 + 88 files changed, 4643 insertions(+), 783 deletions(-) create mode 100644 mobile/src/session/mobile-structured-agent-session-cancel.ts create mode 100644 mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx create mode 100644 src/main/claude/claude-prompt-registry.ts create mode 100644 src/main/claude/claude-structured-journal-prompt-retry.test.ts create mode 100644 src/main/claude/claude-structured-journal-prompts.ts create mode 100644 src/main/claude/claude-structured-prompt-ownership.test.ts create mode 100644 src/main/claude/claude-structured-prompt-ownership.ts create mode 100644 src/main/codex/codex-prompt-registry.ts create mode 100644 src/main/codex/codex-structured-prompt-ownership.test.ts create mode 100644 src/main/codex/codex-structured-prompt-ownership.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 389beb8eaad..a72b91ff29c 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -83,6 +83,7 @@ export function MobileNativeChatOverlay({ onDismissAsk={controller.dismissNativeChatAsk} onAnswerAsk={controller.handleNativeChatAnswerAsk} onCancelAsk={controller.handleNativeChatCancelAsk} + onCancelPrompt={controller.handleNativeChatCancelPrompt} question={controller.nativeChatQuestion} onAnswerQuestion={controller.handleNativeChatQuestionAnswer} permission={controller.nativeChatPermission} diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index b39188b381f..88de20a7ca5 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -10,7 +10,7 @@ vi.mock('react-native', () => ({ View: 'View' })) -vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' })) +vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion', X: 'X' })) describe('MobileNativeChatPermission', () => { let renderer: ReactTestRenderer | null = null @@ -42,4 +42,24 @@ describe('MobileNativeChatPermission', () => { expect(onRespond).toHaveBeenCalledOnce() await act(async () => resolveResponse(true)) }) + + it('passes the rendered prompt identity to cancel', async () => { + const onCancel = vi.fn(async () => true) + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { + title: 'Approve?', + prompt: { itemId: 'approval-1', expectedRevision: 4 }, + options: [{ label: 'Allow', send: '1' }] + }, + onRespond: vi.fn(async () => true), + onCancel + }) + ) + }) + const cancel = renderer.root.findByProps({ accessibilityLabel: 'Cancel' }) + await act(async () => cancel.props.onPress()) + expect(onCancel).toHaveBeenCalledWith({ itemId: 'approval-1', expectedRevision: 4 }) + }) }) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx index ad26d578d93..47ad7a52022 100644 --- a/mobile/src/session/MobileNativeChatPermission.tsx +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -1,6 +1,6 @@ import { memo, useRef, useState } from 'react' import { Pressable, StyleSheet, Text, View } from 'react-native' -import { ShieldQuestion } from 'lucide-react-native' +import { ShieldQuestion, X } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import type { MobileChatPermission } from './mobile-native-chat-permission' @@ -9,10 +9,12 @@ import type { MobileChatPermission } from './mobile-native-chat-permission' // accent button so the affirmative choice reads as distinct from the rest. function MobileNativeChatPermissionImpl({ permission, - onRespond + onRespond, + onCancel }: { permission: MobileChatPermission onRespond: (send: string) => Promise + onCancel?: (prompt?: NonNullable) => Promise }): React.JSX.Element { const [submitting, setSubmitting] = useState(false) const submittingRef = useRef(false) @@ -33,6 +35,17 @@ function MobileNativeChatPermissionImpl({ {permission.title} + {onCancel ? ( + void onCancel(permission.prompt)} + disabled={submitting} + > + + + ) : null} {permission.detail ? {permission.detail} : null} @@ -80,10 +93,17 @@ const styles = StyleSheet.create({ gap: spacing.sm }, title: { + flex: 1, color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, + cancel: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center' + }, detail: { color: colors.textSecondary, fontSize: typography.metaSize, diff --git a/mobile/src/session/MobileNativeChatPromptCard.tsx b/mobile/src/session/MobileNativeChatPromptCard.tsx index 470ba2ee8b6..31a007801dc 100644 --- a/mobile/src/session/MobileNativeChatPromptCard.tsx +++ b/mobile/src/session/MobileNativeChatPromptCard.tsx @@ -15,6 +15,7 @@ export function MobileNativeChatPromptCard({ onDismissAsk, onAnswerAsk, onCancelAsk, + onCancelPrompt, permission, onRespondPermission, question, @@ -25,6 +26,7 @@ export function MobileNativeChatPromptCard({ onDismissAsk?: () => void onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise onCancelAsk?: () => Promise + onCancelPrompt?: (prompt?: NonNullable) => Promise permission?: MobileChatPermission | null onRespondPermission?: (send: string) => Promise question?: MobileChatQuestion | null @@ -58,6 +60,7 @@ export function MobileNativeChatPromptCard({ key={JSON.stringify(permission)} permission={permission} onRespond={async (send) => (await onRespondPermission?.(send)) ?? false} + onCancel={onCancelPrompt} /> ) } @@ -67,6 +70,7 @@ export function MobileNativeChatPromptCard({ key={mobileChatQuestionKey(question)} question={question} onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false} + onCancel={onCancelPrompt} /> ) } diff --git a/mobile/src/session/MobileNativeChatQuestion.test.tsx b/mobile/src/session/MobileNativeChatQuestion.test.tsx index be9777a0b69..96cae6556a4 100644 --- a/mobile/src/session/MobileNativeChatQuestion.test.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.test.tsx @@ -14,7 +14,8 @@ vi.mock('react-native', () => ({ vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', Check: 'Check', - CircleHelp: 'CircleHelp' + CircleHelp: 'CircleHelp', + X: 'X' })) describe('MobileNativeChatQuestion', () => { @@ -103,4 +104,27 @@ describe('MobileNativeChatQuestion', () => { expect(onAnswer).toHaveBeenCalledWith('east-token, other-token:ap-south') }) + + it('passes the rendered prompt identity to cancel', async () => { + const onCancel = vi.fn(async () => true) + await act(async () => { + renderer = create( + createElement(MobileNativeChatQuestion, { + question: { + question: 'Pick one', + prompt: { itemId: 'question-1', expectedRevision: 7 }, + options: ['Choice'], + multiSelect: false, + allowOther: false, + optionTokens: ['choice-token'] + }, + onAnswer: vi.fn(async () => true), + onCancel + }) + ) + }) + const cancel = renderer.root.findByProps({ accessibilityLabel: 'Cancel' }) + await act(async () => cancel.props.onPress()) + expect(onCancel).toHaveBeenCalledWith({ itemId: 'question-1', expectedRevision: 7 }) + }) }) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index 9eae7210bc8..1cb530c7682 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useState } from 'react' import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' -import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' +import { ArrowUp, Check, CircleHelp, X } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { formatQuestionAnswerByIndexes, @@ -12,13 +12,18 @@ import { type Props = { question: MobileChatQuestion onAnswer: (text: string) => Promise + onCancel?: (prompt?: NonNullable) => Promise } /** Renders an agent's choice prompt as a tappable card. Single-select answers * on tap; multi-select toggles then Submits; an always-present text entry lets * the user answer freely (the escape hatch) when the heuristic misreads the * options or none apply. */ -export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element { +export function MobileNativeChatQuestion({ + question, + onAnswer, + onCancel +}: Props): React.JSX.Element { const [selectedOptionIndexes, setSelectedOptionIndexes] = useState([]) const [freeText, setFreeText] = useState('') const [sending, setSending] = useState(false) @@ -102,6 +107,17 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J {question.question} + {onCancel ? ( + void onCancel(question.prompt)} + disabled={sending} + > + + + ) : null} {hasOptions ? ( @@ -214,6 +230,12 @@ const styles = StyleSheet.create({ fontWeight: '600', lineHeight: typography.bodySize + 7 }, + cancel: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center' + }, options: { gap: spacing.xs }, diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 28d2f871cdf..49f20fa5d5d 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -122,6 +122,8 @@ type Props = { * into selector keystrokes (Claude) or pasted label text (other agents). */ onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise onCancelAsk?: () => Promise + /** Cancel a structured approval/question with exact item identity when supported. */ + onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise question?: MobileChatQuestion | null onAnswerQuestion?: (text: string) => Promise permission?: MobileChatPermission | null @@ -178,6 +180,7 @@ export function MobileNativeChatView({ onDismissAsk, onAnswerAsk, onCancelAsk, + onCancelPrompt, question, onAnswerQuestion, permission, @@ -371,6 +374,7 @@ export function MobileNativeChatView({ onDismissAsk={onDismissAsk} onAnswerAsk={onAnswerAsk} onCancelAsk={onCancelAsk} + onCancelPrompt={onCancelPrompt} permission={permission} onRespondPermission={onRespondPermission} question={question} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 58f5bb05741..78c5ebeb1cf 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -57,6 +57,10 @@ export type MobileNativeChatController = { selections: AskAnswerSelection[] ) => Promise handleNativeChatCancelAsk: () => Promise + handleNativeChatCancelPrompt?: (prompt?: { + itemId: string + expectedRevision: number + }) => Promise handleNativeChatRespondPermission: (text: string) => Promise handleNativeChatStop: () => void nativeChatFilePaths: string[] diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts index fe3f9a0d89b..53799718eb4 100644 --- a/mobile/src/session/mobile-native-chat-permission.ts +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -11,6 +11,8 @@ export type MobileChatPermission = { title: string detail?: string + /** Structured prompt identity, present only when the host can cancel it exactly. */ + prompt?: { itemId: string; expectedRevision: number } options: Array<{ label: string; send: string }> } diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts index 59ba3d72aba..66ebb75919f 100644 --- a/mobile/src/session/mobile-native-chat-question.ts +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -5,6 +5,8 @@ export type MobileChatQuestion = { question: string + /** Structured prompt identity, present only for durable host prompts. */ + prompt?: { itemId: string; expectedRevision: number } options: string[] multiSelect: boolean /** Structured questions hide the free-text row when the provider does not accept it. */ diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 7d0c5c6de46..7b3b91af7a6 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,12 +62,12 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = '11cd92aec686a6e47b23114ec31da86152a850b064821578b165fabfbce53b27' -const HEAD_HOOK_BINDING_SHA256 = 'f8bce7101a26b4d794bb58dee54702424a4965cc81dec5c758ca56cd5a6f4ce8' +const HEAD_MAIN_HOOK_SHA256 = 'c7a1bbc0588a5d27797bbab13168e76eb20200288921fdc3347632c2b4afd0ae' +const HEAD_HOOK_BINDING_SHA256 = '06edf1a4314eba41b1d3e1cb67b0cfab2a936aef7d127c5dc48e789c9adc6c8f' const HEAD_CALLBACK_IDENTITY_SHA256 = '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' const HEAD_CALLBACK_BODY_SHA256 = '85c4f4605e66c45e2b6bc7de739cb3493d9e2d0db9c9242c379db8ed34a8cefe' -const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' +const HEAD_EFFECT_SHA256 = '73d80845e0a4b6363cfb4bb55551af97965b1f676b97adf0b2a8504219b9a501' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = '97ce5457d8059974f500022a4382ff687074e26843d6c1525be938d6c0537928' @@ -87,7 +87,7 @@ const HEAD_STYLE_REFERENCE_SHA256 = const HEAD_IDENTITY_FIELD_SHA256 = '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' -const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d' +const HEAD_CAPABILITY_SHA256 = '67c3154b71b542bb63a4365d3ea75aef19ef133c02f509318619618221786fab' type Definition = { declaration: ts.FunctionDeclaration; sourceFile: ts.SourceFile } type HookFacts = { @@ -472,7 +472,7 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(268) + expect(main.hooks).toHaveLength(269) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) expect(main.callbacks).toHaveLength(77) @@ -511,7 +511,7 @@ describe('mobile session route extraction parity', () => { expect(hash(compatibility.identityFields)).toBe(HEAD_IDENTITY_FIELD_SHA256) expect(compatibility.navigation).toHaveLength(6) expect(hash(compatibility.navigation)).toBe(HEAD_NAVIGATION_SHA256) - expect(compatibility.capabilities).toHaveLength(5) + expect(compatibility.capabilities).toHaveLength(6) expect(hash(compatibility.capabilities)).toBe(HEAD_CAPABILITY_SHA256) }) diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts index 61425597721..82d619e49e6 100644 --- a/mobile/src/session/mobile-structured-agent-prompts.ts +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -134,6 +134,7 @@ export function projectStructuredPermission( } return { title: prompt.body.title, + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision }, ...(prompt.body.detail ? { detail: prompt.body.detail } : {}), options: prompt.body.options.map((option) => ({ label: option.label, @@ -158,12 +159,14 @@ export function projectStructuredQuestion( return projectGroupedQuestion( prompt.body.questions, groupedDraft, - groupedQuestionPromptKey(prompt.itemId, prompt.revision) + groupedQuestionPromptKey(prompt.itemId, prompt.revision), + { itemId: prompt.itemId, expectedRevision: prompt.revision } ) } const optionDescriptions = prompt.body.options.map((option) => option.description) return { question: prompt.body.question, + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision }, options: prompt.body.options.map((option) => option.label), ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), multiSelect: false, diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts new file mode 100644 index 00000000000..9c67e7480d0 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts @@ -0,0 +1,78 @@ +import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import type { RpcClient } from '../transport/rpc-client' +import { + requestStructuredAgentSessionMutation, + retainStructuredSessionOperationId, + type StructuredAgentSessionMutationCallResult +} from './mobile-structured-agent-session-rpc' + +type PromptIdentity = { itemId: string; expectedRevision: number } + +export function pendingStructuredPromptIdentity( + items: readonly AgentJournalRenderItem[] +): PromptIdentity | undefined { + const prompt = items.find((item) => + item.body.kind === 'approval' || item.body.kind === 'question' + ? item.body.resolution.state === 'pending' + : false + ) + return prompt ? { itemId: prompt.itemId, expectedRevision: prompt.revision } : undefined +} + +export async function requestMobileStructuredAgentSessionCancel(args: { + client: RpcClient | null + sessionId: string | null + enabled: boolean + stateRef: { readonly current: StructuredAgentSessionState } + sessionKey: string + operationIds: Map + promptCancelSupported: boolean | null + prompt?: PromptIdentity + onSendError: (message: string) => void +}): Promise { + const { client, enabled, onSendError, operationIds, sessionId, sessionKey, stateRef } = args + const current = stateRef.current + const turnId = activeStructuredAgentSessionTurnId(current.items) + if (!client || !sessionId || !enabled || current.fence === null || !turnId) { + onSendError('Stop not sent') + return false + } + // Check the capability before fields enter either the fingerprint or operation key. + const fields = { + turnId, + ...(args.prompt && args.promptCancelSupported === true ? { prompt: args.prompt } : {}) + } + const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}` + const clientOperationId = retainStructuredSessionOperationId( + operationIds, + key, + operationIds.get(key) + ) + const result: StructuredAgentSessionMutationCallResult = + await requestStructuredAgentSessionMutation({ + client, + method: 'agentSession.cancel', + fingerprintMethod: 'agentSession.cancel', + sessionId, + expectedRuntimeFence: current.fence, + fields, + clientOperationId + }) + if (result.status !== 'unknown') { + operationIds.delete(key) + } + if (result.status === 'accepted') { + return true + } + if (result.status === 'unknown') { + onSendError('Stop unconfirmed — check chat before retrying') + } else if (result.status === 'refused') { + onSendError(result.message) + } else if (result.status === 'failed') { + onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message) + } + return false +} diff --git a/mobile/src/session/mobile-structured-grouped-question.ts b/mobile/src/session/mobile-structured-grouped-question.ts index 17a716cd032..031cdf3f10f 100644 --- a/mobile/src/session/mobile-structured-grouped-question.ts +++ b/mobile/src/session/mobile-structured-grouped-question.ts @@ -105,7 +105,8 @@ function answersFor( export function projectGroupedQuestion( questions: readonly AgentJournalQuestion[], draft: GroupedQuestionDraft | null, - promptKey: string + promptKey: string, + promptIdentity?: { itemId: string; expectedRevision: number } ): MobileChatQuestion | null { const answered = answersFor(draft, promptKey).length const question = questions[answered] @@ -117,6 +118,7 @@ export function projectGroupedQuestion( return { question: questions.length > 1 ? `${heading} (${answered + 1} of ${questions.length})` : heading, + ...(promptIdentity ? { prompt: promptIdentity } : {}), options: question.options.map((option) => option.label), ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), multiSelect: question.multiSelect, diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index c90033e2404..00eee9651a0 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -17,6 +17,7 @@ const viewMode = { isTabChatView: (_tabId: string) => true } const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false } const structuredSendWithOutcome = vi.fn() const structuredCancel = vi.fn() +const structuredCancelPrompt = vi.fn(async () => true) const structuredRespondPermission = vi.fn(async () => true) const structuredRespondQuestion = vi.fn(async () => true) const structuredSetOption = vi.fn(async () => true) @@ -90,6 +91,7 @@ vi.mock('./use-mobile-structured-agent-session', () => ({ ...structuredActivity, sendWithOutcome: structuredSendWithOutcome, cancel: structuredCancel, + cancelPrompt: structuredCancelPrompt, permission: structuredPermission, question: structuredQuestion, optionSnapshot: structuredOptionSnapshot, @@ -227,6 +229,10 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { controller = null }) + it('leaves structured prompt cancellation unavailable on the legacy bridge lane', () => { + expect(controller?.handleNativeChatCancelPrompt).toBeUndefined() + }) + it('clears an orphaned image paste before a question-card answer (#10228)', async () => { // The chat overlay wires the question card straight to this send, bypassing // the image hook that used to own the only heal. diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 987546a4f48..b8b99742dd8 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -35,6 +35,8 @@ export function useMobileNativeChatController(args: { nativeChatInputLeaseReady: boolean /** Live socket state; the lease collapses on disconnect but one render later. */ connState: ConnectionState + /** Host capability fact from the shared runtime status probe. */ + agentSessionPromptCancelSupported?: boolean | null onSendError: (message: string) => void /** Retires a held failure banner. Any accepted chat write clears it — a delivered * answer or permission reply must not sit under a stale "not sent". */ @@ -51,6 +53,7 @@ export function useMobileNativeChatController(args: { nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, connState, + agentSessionPromptCancelSupported = null, onSendError, onSendResolved } = args @@ -90,6 +93,7 @@ export function useMobileNativeChatController(args: { callerIdentity: deviceTokenRef.current ?? '', enabled: showNativeChat, connState, + promptCancelSupported: agentSessionPromptCancelSupported, onSendError }) const { @@ -258,6 +262,10 @@ export function useMobileNativeChatController(args: { ? structuredNativeChat.respondPermission : legacyHandleNativeChatRespondPermission const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved) + const structuredCancelPrompt = useNativeChatAcceptedAction( + activeChatStructured ? structuredNativeChat.cancelPrompt : async () => false, + onSendResolved + ) return { isTabChatView, @@ -292,6 +300,9 @@ export function useMobileNativeChatController(args: { dismissNativeChatAsk, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, + // Heuristic/legacy cards have no durable prompt identity, so keep their + // cancel affordance absent instead of exposing a dead action. + handleNativeChatCancelPrompt: activeChatStructured ? structuredCancelPrompt : undefined, handleNativeChatRespondPermission: respond, handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, nativeChatFilePaths, diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts index d337d8ba929..bfeb1b06945 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -15,6 +15,7 @@ export function useMobileNativeChatSessionLane({ sessionId, sourceIdentity, callerIdentity, + promptCancelSupported, enabled, connState, onSendError @@ -29,6 +30,7 @@ export function useMobileNativeChatSessionLane({ sessionId: string | null sourceIdentity: Parameters[0]['sourceIdentity'] callerIdentity: string + promptCancelSupported?: boolean | null enabled: boolean connState: ConnectionState onSendError: (message: string) => void @@ -48,6 +50,7 @@ export function useMobileNativeChatSessionLane({ sessionId: structured ? sessionId : null, sourceIdentity, callerIdentity, + promptCancelSupported, enabled, // Holds are connection-scoped; dropping this on transport loss lets the hook // reacquire the provider without clearing the cached transcript. diff --git a/mobile/src/session/use-mobile-session-feedback-capabilities.ts b/mobile/src/session/use-mobile-session-feedback-capabilities.ts index 8c619fb1f7f..f0231189309 100644 --- a/mobile/src/session/use-mobile-session-feedback-capabilities.ts +++ b/mobile/src/session/use-mobile-session-feedback-capabilities.ts @@ -32,6 +32,11 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina null ) const [quickCommandsSupported, setQuickCommandsSupported] = useState(null) + // Prompt cancellation is negotiated with the same host capability probe as + // the other session surfaces; consumers never maintain a second status cache. + const [agentSessionPromptCancelSupported, setAgentSessionPromptCancelSupported] = useState< + boolean | null + >(null) // Why: stable callbacks (handleFileTap) read the live value via this ref, since // the capability probe resolves after the callbacks are created. const browserScreencastSupportedRef = useRef(browserScreencastSupported) @@ -115,6 +120,8 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina setAgentSessionHistorySupported, quickCommandsSupported, setQuickCommandsSupported, + agentSessionPromptCancelSupported, + setAgentSessionPromptCancelSupported, browserScreencastSupportedRef, reconciledCreateWarningState, createWarning, diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 6046cba1059..535b4034ec8 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -27,6 +27,7 @@ export function useMobileSessionNativeChatDictation( worktreeId, client, connState, + agentSessionPromptCancelSupported, setInput, liveInputTerminalHandles, activeHandle, @@ -72,6 +73,7 @@ export function useMobileSessionNativeChatDictation( nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, connState, + agentSessionPromptCancelSupported, onSendError: nativeChatSendError.show, onSendResolved: nativeChatSendError.clear }) diff --git a/mobile/src/session/use-mobile-session-tab-reconciliation.ts b/mobile/src/session/use-mobile-session-tab-reconciliation.ts index be4641dd297..da7a48e035c 100644 --- a/mobile/src/session/use-mobile-session-tab-reconciliation.ts +++ b/mobile/src/session/use-mobile-session-tab-reconciliation.ts @@ -2,7 +2,10 @@ import { useEffect, useRef, useCallback, useMemo, useState } from 'react' import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe' import { supportsMobileQuickCommands } from '../terminal/quick-commands' import { MOBILE_AI_VAULT_CAPABILITY } from '../agent-history/agent-history-capability' -import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' import { runAcceptedMobileSessionTabsEffects } from './mobile-session-tabs-accepted-effects' import type { SessionTabsStreamSource } from './mobile-session-tabs-stream-health' import { useMobileSessionTabsFetchReporting } from './use-mobile-session-tabs-fetch-reporting' @@ -31,6 +34,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc switchSessionTabRef, setBrowserScreencastSupported, setAgentSessionHistorySupported, + setAgentSessionPromptCancelSupported, setQuickCommandsSupported, nativeChatStream, fetchTerminals, @@ -148,6 +152,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + setAgentSessionPromptCancelSupported(null) setQuickCommandsSupported(null) setShowQuickCommands(false) hostQueryReplyInputSupportedRef.current = false @@ -157,6 +162,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc // host; clear the prior capability before exposing host-specific actions. setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + setAgentSessionPromptCancelSupported(null) setQuickCommandsSupported(null) setShowQuickCommands(false) hostQueryReplyInputSupportedRef.current = false @@ -165,6 +171,9 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc return startRuntimeCapabilityProbe(client, (capabilities) => { setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1')) setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY)) + setAgentSessionPromptCancelSupported( + capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) + ) setQuickCommandsSupported(supportsMobileQuickCommands(capabilities)) // Why: hosts without this capability strip inputKind from terminal.send, // so a forwarded xterm reply would become floor-stealing shell input. diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx new file mode 100644 index 00000000000..48b44e78ff5 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -0,0 +1,221 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import type { RpcClient } from '../transport/rpc-client' + +const mocks = vi.hoisted(() => ({ sendRequest: vi.fn() })) +vi.mock('./use-mobile-structured-agent-state', () => ({ + useMobileStructuredAgentState: () => ({ + state, + stateRef, + loadingOlder: false, + loadEarlier: vi.fn() + }) +})) +vi.mock('./use-mobile-structured-agent-options', () => ({ + useMobileStructuredAgentOptions: () => ({ + conversationCommands: [], + invokeStructuredOption: vi.fn(), + optionSnapshot: [], + optionSurface: { getSnapshot: () => [], subscribe: () => () => {} }, + pendingOptionId: null, + setStructuredOption: vi.fn() + }) +})) +vi.mock('./use-mobile-structured-prompt-responses', () => ({ + useMobileStructuredPromptResponses: () => ({ + groupedDraft: null, + respondPermission: vi.fn(), + respondQuestion: vi.fn() + }) +})) +vi.mock('./use-mobile-structured-send-operation-reconciliation', () => ({ + useMobileStructuredSendOperationReconciliation: vi.fn() +})) + +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +const pendingApproval = (): AgentJournalRenderItem => ({ + itemId: 'approval-1', + revision: 4, + sequence: 2, + observedAt: 2, + body: { + kind: 'approval', + title: 'Allow Bash?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } +}) + +const runningTurn = (): AgentJournalRenderItem => ({ + itemId: 'turn-status', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + text: 'Waiting', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } +}) + +const pendingQuestion = (): AgentJournalRenderItem => ({ + itemId: 'question-1', + revision: 7, + sequence: 2, + observedAt: 2, + body: { + kind: 'question', + question: 'Pick a destination', + options: [{ id: 'local', label: 'Local' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } +}) + +let state: StructuredAgentSessionState +const stateRef = { + get current(): StructuredAgentSessionState { + return state + } +} +const client: RpcClient = { + sendRequest: mocks.sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} +} + +function Harness({ promptCancelSupported }: { promptCancelSupported: boolean }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'codex', + promptCancelSupported, + onSendError: vi.fn() + }) + return null +} + +let hook: ReturnType +let renderer: ReactTestRenderer | null = null + +describe('mobile structured prompt cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + state = { + epoch: 'epoch-1', + cursor: { epoch: 'epoch-1', sequence: 2 }, + fence: 3, + items: [runningTurn(), pendingApproval()], + submissions: [], + retainedItemLimit: 1024, + hasOlder: false, + status: 'ready', + handoff: null + } + mocks.sendRequest.mockResolvedValue({ + ok: true, + result: { + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 3 }, + value: { turnId: 'turn-1', cancelled: true } + } + }) + renderer = null + }) + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('sends the clicked prompt identity on capable hosts', async () => { + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + await act(async () => { + expect(await hook.cancelPrompt()).toBe(true) + }) + expect(mocks.sendRequest).toHaveBeenCalledWith( + 'agentSession.cancel', + expect.objectContaining({ + turnId: 'turn-1', + prompt: { itemId: 'approval-1', expectedRevision: 4 } + }), + expect.any(Object) + ) + }) + + it('downgrades to turn-only cancellation on an old host', async () => { + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: false })) + }) + await act(async () => { + expect(await hook.cancelPrompt()).toBe(true) + }) + const call = mocks.sendRequest.mock.calls.find(([method]) => method === 'agentSession.cancel') + expect(call?.[1]).toMatchObject({ turnId: 'turn-1' }) + expect(call?.[1]).not.toHaveProperty('prompt') + }) + + it('cancels a question card with its item identity', async () => { + state = { ...state, items: [runningTurn(), pendingQuestion()] } + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + await act(async () => { + expect(await hook.cancelPrompt({ itemId: 'question-1', expectedRevision: 7 })).toBe(true) + }) + expect(mocks.sendRequest).toHaveBeenCalledWith( + 'agentSession.cancel', + expect.objectContaining({ + turnId: 'turn-1', + prompt: { itemId: 'question-1', expectedRevision: 7 } + }), + expect.any(Object) + ) + }) + + it('uses the rendered prompt identity when the journal changes before tap', async () => { + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + const renderedIdentity = { itemId: 'approval-1', expectedRevision: 4 } + state = { + ...state, + items: [runningTurn(), { ...pendingApproval(), itemId: 'approval-new', revision: 9 }] + } + // The hook API accepts the identity captured by the card; the state is intentionally newer. + await act(async () => { + expect(await hook.cancelPrompt(renderedIdentity)).toBe(true) + }) + expect(mocks.sendRequest).toHaveBeenCalledWith( + 'agentSession.cancel', + expect.objectContaining({ prompt: renderedIdentity }), + expect.any(Object) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 301c9b7b0ca..2a24d38a38e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { dispatchMobileStructuredCommand } from './mobile-structured-composer-command' -import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -37,6 +36,10 @@ import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-o import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import { useMobileStructuredSendOperationReconciliation } from './use-mobile-structured-send-operation-reconciliation' +import { + pendingStructuredPromptIdentity, + requestMobileStructuredAgentSessionCancel +} from './mobile-structured-agent-session-cancel' type StructuredMobileAttachment = StructuredAgentSessionAttachment & { id?: string @@ -61,6 +64,7 @@ type StructuredMobileSession = ReturnType Promise respondQuestion: (answer: string) => Promise + cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise } export function useMobileStructuredAgentSession(args: { @@ -73,6 +77,8 @@ export function useMobileStructuredAgentSession(args: { enabled: boolean /** Live transport only; gates the connection-scoped hold, nothing else. */ connected: boolean + /** Capability fact from the shared runtime status probe; null follows legacy cancellation. */ + promptCancelSupported?: boolean | null agent: string | null onSendError: (message: string) => void }): StructuredMobileSession { @@ -84,14 +90,13 @@ export function useMobileStructuredAgentSession(args: { sessionId, sourceIdentity = '', enabled, - onSendError + onSendError, + promptCancelSupported = null } = args const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) const operationIdsRef = useRef(new Map()) const commandPendingRef = useRef(false) useEffect(() => () => operationIdsRef.current.clear(), []) - const retainOperationId = (key: string, operationId?: string): string => - retainStructuredOpId(operationIdsRef.current, key, operationId) const stateArgs = { client, sessionId, sessionKey, enabled, connected } const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs) useMobileStructuredSendOperationReconciliation(state.submissions) @@ -108,7 +113,11 @@ export function useMobileStructuredAgentSession(args: { } const targetFence = current.fence const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` - const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) + const clientOperationId = retainStructuredOpId( + operationIdsRef.current, + key, + operationIdsRef.current.get(key) + ) const result = await requestStructuredAgentSessionMutation({ client, method, @@ -127,9 +136,6 @@ export function useMobileStructuredAgentSession(args: { } } if (result.status === 'unknown') { - // Prompt/option plans cannot repeat a harmful effect under a fresh id; - // issue a fresh id so a retry can be admitted after the user checks the - // stream. Sends keep theirs — see `mobile-structured-send-delivery.ts`. operationIdsRef.current.delete(key) return result } @@ -230,7 +236,6 @@ export function useMobileStructuredAgentSession(args: { setStructuredOption ] ) - const { groupedDraft, respondPermission, respondQuestion } = useMobileStructuredPromptResponses({ stateRef, sessionKey, @@ -238,37 +243,21 @@ export function useMobileStructuredAgentSession(args: { onSendError }) - const cancel = useCallback(() => { - const current = stateRef.current - const turnId = activeStructuredAgentSessionTurnId(current.items) - if (!client || !sessionId || !enabled || current.fence === null || !turnId) { - onSendError('Stop not sent') - return - } - const fields = { turnId } - const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}` - const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) - void requestStructuredAgentSessionMutation({ - client, - method: 'agentSession.cancel', - fingerprintMethod: 'agentSession.cancel', - sessionId, - expectedRuntimeFence: current.fence, - fields, - clientOperationId - }).then((result) => { - if (result.status !== 'unknown') { - operationIdsRef.current.delete(key) - } - if (result.status === 'unknown') { - onSendError('Stop unconfirmed — check chat before retrying') - } else if (result.status === 'refused') { - onSendError(result.message) - } else if (result.status === 'failed') { - onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message) - } - }) - }, [client, enabled, onSendError, sessionId, sessionKey]) + const requestCancel = useCallback( + (prompt?: { itemId: string; expectedRevision: number }): Promise => + requestMobileStructuredAgentSessionCancel({ + client, + enabled, + onSendError, + operationIds: operationIdsRef.current, + prompt, + promptCancelSupported, + sessionId, + sessionKey, + stateRef + }), + [client, enabled, onSendError, promptCancelSupported, sessionId, sessionKey, stateRef] + ) const messages = useMemo( () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), @@ -279,8 +268,6 @@ export function useMobileStructuredAgentSession(args: { const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state.items) - // Stable while the readings hold, so a streaming turn does not re-render the - // whole chat surface on every journal batch. const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText]) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( @@ -291,7 +278,6 @@ export function useMobileStructuredAgentSession(args: { () => state.items.find(pendingStructuredQuestion) ?? null, [state.items] ) - return { ...options, session: { @@ -303,7 +289,6 @@ export function useMobileStructuredAgentSession(args: { loadingEarlier: loadingOlder, loadEarlier }, - // A dispatch the provider has not answered yet is already work — see the desktop hook. isWorking: turnId !== null || hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence), @@ -311,7 +296,11 @@ export function useMobileStructuredAgentSession(args: { turnIndicator, ...turnTiming, sendWithOutcome, - cancel, + cancel: () => { + void requestCancel() + }, + cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => + requestCancel(prompt ?? pendingStructuredPromptIdentity(stateRef.current.items)), permission: projectStructuredPermission(approvalPrompt), question: projectStructuredQuestion(questionPrompt, groupedDraft), respondPermission, diff --git a/src/main/claude/claude-prompt-registry.ts b/src/main/claude/claude-prompt-registry.ts new file mode 100644 index 00000000000..6411a19e215 --- /dev/null +++ b/src/main/claude/claude-prompt-registry.ts @@ -0,0 +1,224 @@ +import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' + +/** Settles the SDK's `canUseTool` promise; `null` writes no provider response. */ +export type ClaudePromptSettle = (response: PermissionResult | null) => void + +export type ClaudePendingPrompt = { + requestId: string + promptKey: string + toolUseId: string + toolName: string + kind: 'approval' | 'question' + input: Record + suggestions: PermissionUpdate[] + questionIds: readonly string[] + answers: Map + settle: ClaudePromptSettle + turnId?: string | null +} + +export type ClaudePromptRegistration = { + requestId: string + toolName: string + toolUseId: string + input: Record + suggestions: PermissionUpdate[] + settle: ClaudePromptSettle + turnId?: string | null +} + +type PromptBinding = { + address: string + questionId?: string + turnId: string | null +} + +export type ClaudePromptClaim = { + readonly itemId: string + readonly found: { prompt: ClaudePendingPrompt; questionId?: string } +} + +type ClaudePromptCancellationObservation = { + promise: Promise + resolve: () => void +} + +export function isClaudePromptRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readClaudePromptString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +export function claudePromptQuestions(input: Record): Record[] { + return Array.isArray(input.questions) ? input.questions.filter(isClaudePromptRecord) : [] +} + +function questionId(question: Record, index: number): string { + return ( + readClaudePromptString(question.question) ?? + readClaudePromptString(question.header) ?? + `question-${index + 1}` + ) +} + +/** Session-local callback ownership; none of this state is reconstructed from the transcript. */ +export class ClaudePromptRegistry { + private readonly prompts = new Map() + private readonly journalBindings = new Map() + private readonly claims = new Map() + private readonly cancellationObservations = new WeakMap< + ClaudePendingPrompt, + ClaudePromptCancellationObservation + >() + + register(registration: ClaudePromptRegistration): ClaudePendingPrompt | null { + const toolUseId = readClaudePromptString(registration.toolUseId) + const toolName = readClaudePromptString(registration.toolName) + const input = isClaudePromptRecord(registration.input) ? registration.input : null + if (!toolUseId || !toolName || !input) { + return null + } + const questions = toolName === 'AskUserQuestion' ? claudePromptQuestions(input) : [] + const prompt: ClaudePendingPrompt = { + requestId: registration.requestId, + promptKey: registration.requestId, + toolUseId, + toolName, + kind: questions.length > 0 ? 'question' : 'approval', + input, + suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [], + questionIds: questions.map(questionId), + answers: new Map(), + settle: registration.settle, + turnId: registration.turnId ?? null + } + this.prompts.set(prompt.promptKey, prompt) + return prompt + } + + /** True only if the prompt was still pending; lets abort and answer settle once. */ + forgetIfPending(prompt: ClaudePendingPrompt): boolean { + if (!this.prompts.has(prompt.promptKey)) { + return false + } + const observation = this.cancellationObservations.get(prompt) + this.forget(prompt) + observation?.resolve() + return true + } + + bindJournalItemId( + journalItemId: string, + promptKey: string, + questionIdForItem?: string, + turnId: string | null = null + ): void { + const prompt = this.prompts.get(promptKey) + this.journalBindings.set(journalItemId, { + address: promptKey, + ...(questionIdForItem ? { questionId: questionIdForItem } : {}), + turnId: turnId ?? prompt?.turnId ?? null + }) + } + + find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { + const binding = this.journalBindings.get(itemId) + const prompt = this.prompts.get(binding?.address ?? itemId) + return prompt + ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } + : null + } + + claim(itemId: string, kind?: 'approval' | 'question'): ClaudePromptClaim | null { + const found = this.find(itemId) + if (!found || this.claims.has(found.prompt) || (kind && found.prompt.kind !== kind)) { + return null + } + const claim = { itemId, found } + this.claims.set(found.prompt, claim) + return claim + } + + claimBound(itemId: string, turnId: string): ClaudePromptClaim | null { + const binding = this.journalBindings.get(itemId) + const prompt = binding ? this.prompts.get(binding.address) : undefined + if (!binding || !prompt || binding.turnId !== turnId || this.claims.has(prompt)) { + return null + } + const found = { prompt, ...(binding.questionId ? { questionId: binding.questionId } : {}) } + const claim = { itemId, found } + this.claims.set(prompt, claim) + return claim + } + + ownsClaim(claim: ClaudePromptClaim): boolean { + return ( + this.claims.get(claim.found.prompt) === claim && + this.find(claim.itemId)?.prompt === claim.found.prompt + ) + } + + ownsBoundClaim(claim: ClaudePromptClaim, itemId: string, turnId: string): boolean { + const binding = this.journalBindings.get(itemId) + return ( + claim.itemId === itemId && + this.claims.get(claim.found.prompt) === claim && + binding?.address === claim.found.prompt.promptKey && + binding.turnId === turnId && + this.prompts.get(binding.address) === claim.found.prompt + ) + } + + releaseClaim(claim: ClaudePromptClaim): void { + if (this.claims.get(claim.found.prompt) === claim) { + this.claims.delete(claim.found.prompt) + } + } + + observeCancellation(claim: ClaudePromptClaim): Promise | null { + if (!this.ownsClaim(claim)) { + return null + } + let observation = this.cancellationObservations.get(claim.found.prompt) + if (!observation) { + let resolve = (): void => {} + const promise = new Promise((settled) => { + resolve = settled + }) + observation = { promise, resolve } + this.cancellationObservations.set(claim.found.prompt, observation) + } + return observation.promise + } + + cancel(requestId: string): ClaudePendingPrompt | null { + const prompt = this.prompts.get(requestId) ?? null + if (prompt) { + this.forget(prompt) + } + return prompt + } + + forget(prompt: ClaudePendingPrompt): void { + this.claims.delete(prompt) + this.prompts.delete(prompt.promptKey) + for (const [itemId, binding] of this.journalBindings) { + if (binding.address === prompt.promptKey) { + this.journalBindings.delete(itemId) + } + } + } + + clear(): ClaudePendingPrompt[] { + const pending = [...this.prompts.values()] + this.prompts.clear() + this.journalBindings.clear() + this.claims.clear() + for (const prompt of pending) { + this.cancellationObservations.get(prompt)?.resolve() + } + return pending + } +} diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 168ce558f53..e9afa9bac25 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -4,10 +4,12 @@ import { answerClaudePrompt, stopClaudeBackgroundTasks } from './claude-structured-control-actions' +import { dispatchClaudeTurn } from './claude-structured-dispatch' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' type InterruptResult = Awaited> @@ -23,11 +25,11 @@ function sessionWith(input: { } { const interrupt = vi.fn(input.interrupt) const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => {})) - const session = { - capabilities: input.capabilities ?? [], - prompts: input.prompts ?? new ClaudePromptRegistry(), - connection: { interrupt, cancelAsyncMessage } - } as unknown as ClaudeSession + const session = sessionFor() + session.capabilities = input.capabilities ?? [] + session.prompts = input.prompts ?? new ClaudePromptRegistry() + session.connection.interrupt = interrupt + session.connection.cancelAsyncMessage = cancelAsyncMessage return { session, interrupt, cancelAsyncMessage } } @@ -54,15 +56,69 @@ describe('cancelClaudeTurn', () => { expect(cancelAsyncMessage.mock.calls.map((call) => call[0])).toEqual(['queued-1', 'queued-2']) }) - it('sends cancel_queued and never sweeps when the CLI advertises the capability', async () => { + it('settles every cancelled queued waiter when the CLI advertises the capability', async () => { + const cancelled = Array.from({ length: 64 }, (_, index) => `queued-${index}`) const { session, interrupt, cancelAsyncMessage } = sessionWith({ capabilities: ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1'], - interrupt: async () => ({ still_queued: [], cancelled: ['queued-1'] }) + interrupt: async () => ({ still_queued: [], cancelled }) }) + const resolutions = cancelled.map(() => vi.fn()) + session.dispatchWaiters = cancelled.map((sentUuid, index): ClaudeDispatchWaiter => ({ + acceptsResult: false, + clientMessageId: `client-${index}`, + sentUuid, + dispatchSequence: index + 1, + replayContentKey: `content-${index}`, + resolve: resolutions[index]! + })) + const settled = vi.fn() - await expect(cancelClaudeTurn(session, 5_000)).resolves.toEqual({ cancelled: true }) + await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ + cancelled: true + }) expect(interrupt).toHaveBeenCalledWith({ cancelQueued: true, timeoutMs: 5_000 }) expect(cancelAsyncMessage).not.toHaveBeenCalled() + expect(session.dispatchWaiters).toEqual([]) + expect(resolutions.every((resolve) => resolve.mock.calls[0]?.[0] === null)).toBe(true) + expect(settled).toHaveBeenCalledTimes(64) + expect(settled).toHaveBeenNthCalledWith(1, { + clientMessageId: 'client-0', + state: 'rejected', + reason: 'provider_cancelled_before_start' + }) + }) + + it('rejects an ambiguously written dispatch when a later interrupt confirms it was cancelled', async () => { + let cancelledUuid = '' + const { session } = sessionWith({ + capabilities: ['interrupt_cancel_queued_v1'], + interrupt: async () => ({ still_queued: [], cancelled: [cancelledUuid] }) + }) + session.connection.send = vi.fn(async () => { + throw new Error('connection lost after write') + }) + const settled = vi.fn() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-ambiguous', + body: userMessage([{ type: 'text', text: 'queued' }]) + }) + ).resolves.toMatchObject({ state: 'unknown' }) + expect(session.dispatchWaiters).toEqual([]) + expect(session.retiredDispatchWaiters).toHaveLength(1) + cancelledUuid = session.retiredDispatchWaiters[0]!.sentUuid + + await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ + cancelled: true + }) + expect(session.retiredDispatchWaiters).toEqual([]) + expect(settled).toHaveBeenCalledOnce() + expect(settled).toHaveBeenCalledWith({ + clientMessageId: 'client-ambiguous', + state: 'rejected', + reason: 'provider_cancelled_before_start' + }) }) it('reports a not-running interrupt as not cancelled without throwing', async () => { @@ -87,6 +143,40 @@ describe('cancelClaudeTurn', () => { }) describe('answerClaudePrompt', () => { + it('resolves cancellation observation when teardown clears the prompt registry', async () => { + const prompts = new ClaudePromptRegistry() + const settle = vi.fn() + const prompt = prompts.register({ + requestId: 'perm-clear', + toolName: 'Bash', + toolUseId: 'tool-clear', + input: { command: 'ls' }, + suggestions: [], + settle + })! + prompts.bindJournalItemId('journal-clear', prompt.promptKey) + const claim = prompts.claim('journal-clear', 'approval') + if (!claim) { + throw new Error('expected prompt claim') + } + const observed = prompts.observeCancellation(claim) + if (!observed) { + throw new Error('expected cancellation observation') + } + let observedCancellation = false + void observed.then(() => { + observedCancellation = true + }) + + expect(prompts.clear()).toEqual([prompt]) + await Promise.resolve() + + expect(observedCancellation).toBe(true) + expect(prompts.find('journal-clear')).toBeNull() + expect(prompts.ownsClaim(claim)).toBe(false) + expect(settle).not.toHaveBeenCalled() + }) + it('settles the pending prompt callback and forgets it', async () => { const prompts = new ClaudePromptRegistry() const settle = vi.fn() @@ -100,20 +190,34 @@ describe('answerClaudePrompt', () => { })! prompts.bindJournalItemId('journal-1', prompt.promptKey) const { session } = sessionWith({ interrupt: async () => undefined, prompts }) + const resolvePrompt = vi.fn() + session.translator = { + handle: vi.fn(), + journalPrompts: { + cancel: vi.fn(() => ({ accepted: true as const })), + resolve: resolvePrompt + }, + flush: vi.fn(), + pendingStreamedBlocks: 0, + dispose: vi.fn() + } - await answerClaudePrompt(session, { itemId: 'journal-1', kind: 'approval', optionId: 'allow' }) + const claim = prompts.claim('journal-1', 'approval') + if (!claim) { + throw new Error('expected prompt claim') + } + await answerClaudePrompt(session, claim, 'allow') expect(settle).toHaveBeenCalledWith( expect.objectContaining({ behavior: 'allow', toolUseID: 'tool-1' }) ) expect(prompts.find('journal-1')).toBeNull() + expect(resolvePrompt).toHaveBeenCalledWith(prompt.promptKey) }) - it('refuses an answer for a prompt Claude is no longer waiting on', async () => { - const { session } = sessionWith({ interrupt: async () => undefined }) - await expect( - answerClaudePrompt(session, { itemId: 'missing', kind: 'approval', optionId: 'allow' }) - ).rejects.toThrow(/no longer waiting/) + it('refuses to claim a prompt Claude is no longer waiting on', () => { + const prompts = new ClaudePromptRegistry() + expect(prompts.claim('missing', 'approval')).toBeNull() }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 8b3bb94c7b5..961b9aee7ba 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -1,9 +1,17 @@ -import { applyClaudePromptAnswer } from './claude-structured-prompt-replies' +import { applyClaudePromptAnswer, type ClaudePromptClaim } from './claude-structured-prompt-replies' import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + settleCancelledClaudeDispatchWaiters, + type ClaudeLateDispatchSettlement +} from './claude-structured-dispatch' import type { ClaudeSession } from './claude-structured-session-state' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' +export function supportsClaudeQueuedInterruptCancellation(session: ClaudeSession): boolean { + return session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) +} + export type ClaudeTurnCancellationGuard = () => boolean /** @@ -16,20 +24,23 @@ export type ClaudeTurnCancellationGuard = () => boolean export async function cancelClaudeTurn( session: ClaudeSession, timeoutMs: number | undefined, - isCurrent: ClaudeTurnCancellationGuard = () => true + isCurrent: ClaudeTurnCancellationGuard = () => true, + onDispatchSettledLate?: ClaudeLateDispatchSettlement ): Promise<{ cancelled: boolean }> { // The SDK interrupt is session-scoped. Re-check the caller's turn/fence // immediately before issuing it so a delayed request cannot stop a later turn. if (!isCurrent()) { return { cancelled: false } } - const cancelQueued = session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) + const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), timeoutMs }) - if (!cancelQueued) { + if (cancelQueued) { + settleCancelledClaudeDispatchWaiters(session, receipt?.cancelled ?? [], onDispatchSettledLate) + } else { for (const uuid of receipt?.still_queued ?? []) { await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => {}) } @@ -71,16 +82,18 @@ export async function stopClaudeBackgroundTasks( export async function answerClaudePrompt( session: ClaudeSession, - input: { itemId: string; kind: 'approval' | 'question'; optionId: string } + claim: ClaudePromptClaim, + optionId: string ): Promise { - const found = session.prompts.find(input.itemId) - if (!found || found.prompt.kind !== input.kind) { - throw new Error(`claude is no longer waiting on ${input.itemId}`) + if (!session.prompts.ownsClaim(claim)) { + throw new Error(`claude is no longer waiting on ${claim.itemId}`) } - const response = applyClaudePromptAnswer(found, input.optionId) + const response = applyClaudePromptAnswer(claim.found, optionId) if (response === null) { + session.prompts.releaseClaim(claim) return } - session.prompts.forget(found.prompt) - found.prompt.settle(response) + session.prompts.forget(claim.found.prompt) + claim.found.prompt.settle(response) + session.translator?.journalPrompts.resolve(claim.found.prompt.promptKey) } diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 45dc3ebc0e3..84253b765f6 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -1,14 +1,15 @@ import { randomUUID } from 'node:crypto' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeHasReplayContent, readClaudeMessageEnvelope } from './claude-structured-item-translation' -import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' +import type { + ClaudeDispatchWaiter, + ClaudeLateDispatchOutcome, + ClaudeSession +} from './claude-structured-session-state' import { readClaudeFrameString } from './claude-structured-init-proof' import { claudeDispatchContentKey, @@ -17,6 +18,7 @@ import { } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { + DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_QUEUE_FULL, dispatchWriteFailureReason } from '../../shared/structured-agent-session-dispatch-rejection' @@ -25,11 +27,8 @@ import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-m const MAX_RETIRED_DISPATCH_WAITERS = 64 const MAX_ACTIVE_DISPATCH_WAITERS = 64 -/** Directly settles provider-proven delivery; the durable replay row independently reconciles it. */ -export type ClaudeLateDispatchSettlement = (input: { - clientMessageId: string - providerIdentity: AgentJournalItemIdentity -}) => void +/** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */ +export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void export function resolveClaudeReplayWaiter( session: ClaudeSession, @@ -228,6 +227,34 @@ function forgetWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): voi } } +export function settleCancelledClaudeDispatchWaiters( + session: ClaudeSession, + cancelledUuids: readonly string[], + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const cancelled = new Set(cancelledUuids) + const activeWaiters = session.dispatchWaiters.filter((waiter) => cancelled.has(waiter.sentUuid)) + const retiredWaiters = session.retiredDispatchWaiters.filter((waiter) => + cancelled.has(waiter.sentUuid) + ) + for (const waiter of activeWaiters) { + forgetWaiter(session, waiter) + waiter.resolve(null) + } + for (const waiter of retiredWaiters) { + forgetRetiredWaiter(session, waiter) + } + for (const waiter of [...activeWaiters, ...retiredWaiters]) { + if (waiter.clientMessageId) { + onSettledLate?.({ + clientMessageId: waiter.clientMessageId, + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + } + } +} + function retireWaiter(session: ClaudeSession, waiter: ClaudeDispatchWaiter): void { forgetWaiter(session, waiter) if (!waiter.retired) { diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts index 343e76d4ea5..27a90181aec 100644 --- a/src/main/claude/claude-structured-inbound-control.ts +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -25,6 +25,7 @@ export type ClaudePermissionCallbackDeps = { sessionId: string prompts: ClaudePromptRegistry emit: (event: ClaudeStructuredSessionEvent) => void + currentTurnId?: () => string | null } function denySafeResult(toolUseId: string | undefined): PermissionResult { @@ -36,7 +37,7 @@ function denySafeResult(toolUseId: string | undefined): PermissionResult { } /** - * Build the SDK permission callbacks from the durable prompt registry. + * Build the SDK permission callbacks from the session-local prompt registry. * * A decodable `can_use_tool` becomes a durable prompt whose `settle` resolves this callback; * a malformed one is denied without registering. The SDK's abort signal fires on @@ -57,7 +58,8 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep toolUseId: options.toolUseID, input, suggestions: options.suggestions ?? [], - settle: resolve as (response: Record | null) => void + settle: resolve, + turnId: deps.currentTurnId?.() ?? null }) if (!prompt) { resolve(denySafeResult(options.toolUseID)) diff --git a/src/main/claude/claude-structured-journal-prompt-retry.test.ts b/src/main/claude/claude-structured-journal-prompt-retry.test.ts new file mode 100644 index 00000000000..491152668ed --- /dev/null +++ b/src/main/claude/claude-structured-journal-prompt-retry.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function approval(promptKey: string): ClaudePendingPrompt { + return { + requestId: promptKey, + promptKey, + toolUseId: 'tool-retry', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: () => {} + } +} + +function transientBackpressureSink( + refusedAt: 'append' | 'publish', + persistent = false +): { + sink: StructuredAgentSessionEventSink + durableApproval: () => AgentJournalItemBody | undefined + appendAttempts: () => number + publishAttempts: () => number + appliedSettlements: Set + release: () => void +} { + const staged = new Map() + const durable = new Map() + const appliedSettlements = new Set() + let lifecycleAppendAttempts = 0 + let lifecyclePublishAttempts = 0 + let released = false + const persist = (): void => { + durable.clear() + for (const [key, body] of staged) { + durable.set(key, body) + } + } + const applyItem = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => { + staged.set(agentJournalItemKey(identity), body) + } + return { + sink: { + appendItem: applyItem, + appendTombstone: (identity) => staged.delete(agentJournalItemKey(identity)), + publish: persist, + tryAppendLifecycleBatch: (settlementId, mutations) => { + lifecycleAppendAttempts += 1 + if (refusedAt === 'append' && (persistent ? !released : lifecycleAppendAttempts === 1)) { + return { accepted: false, reason: 'backpressure' } + } + if (!appliedSettlements.has(settlementId)) { + for (const mutation of mutations) { + if (mutation.kind === 'item') { + applyItem(mutation.identity, mutation.body) + } else { + staged.delete(agentJournalItemKey(mutation.identity)) + } + } + appliedSettlements.add(settlementId) + } + return { accepted: true } + }, + tryPublish: () => { + lifecyclePublishAttempts += 1 + if (refusedAt === 'publish' && (persistent ? !released : lifecyclePublishAttempts === 1)) { + return { accepted: false, reason: 'backpressure' } + } + persist() + return { accepted: true } + } + }, + durableApproval: () => [...durable.values()].find((body) => body.kind === 'approval'), + appendAttempts: () => lifecycleAppendAttempts, + publishAttempts: () => lifecyclePublishAttempts, + appliedSettlements, + release: () => { + released = true + } + } +} + +function rootResult() { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'result', + subtype: 'success', + uuid: 'result-success', + session_id: 'claude-session', + parent_tool_use_id: null, + is_error: false, + duration_ms: 1 + } + } +} + +function streamDelta(index: number) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'stream_event', + uuid: `stream-${index}`, + session_id: 'claude-session', + parent_tool_use_id: null, + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'x' } + } + } + } +} + +describe('Claude journal prompt cancellation retry', () => { + it.each(['append', 'publish'] as const)( + 'retries after transient lifecycle %s backpressure', + (refusedAt) => { + const state = transientBackpressureSink(refusedAt) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const prompt = approval('permission-retry') + + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt }) + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: prompt.promptKey + }) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'pending' } }) + + translator.handle(rootResult()) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'cancelled' } }) + expect(state.appendAttempts()).toBe(2) + expect(state.publishAttempts()).toBe(refusedAt === 'publish' ? 2 : 1) + expect(state.appliedSettlements).toEqual(new Set(['prompt-cancelled:permission-retry'])) + + translator.handle(rootResult()) + expect(state.appendAttempts()).toBe(2) + expect(state.publishAttempts()).toBe(refusedAt === 'publish' ? 2 : 1) + } + ) + + it('keeps streaming frames off retry work and recovers at the next root result', () => { + const state = transientBackpressureSink('append', true) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const prompt = approval('permission-streaming') + + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt }) + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: prompt.promptKey + }) + expect(state.appendAttempts()).toBe(1) + + for (let index = 0; index < 100; index += 1) { + translator.handle(streamDelta(index)) + } + expect(state.appendAttempts()).toBe(1) + + state.release() + translator.handle(rootResult()) + expect(state.appendAttempts()).toBe(2) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'cancelled' } }) + }) +}) diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts new file mode 100644 index 00000000000..3c660223257 --- /dev/null +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -0,0 +1,191 @@ +import type { + AgentJournalApprovalItem, + AgentJournalItemIdentity, + AgentJournalQuestionItem +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + claudeApprovalItem, + claudePromptIdentity, + claudeQuestionItems, + type ClaudeQuestionItem +} from './claude-structured-prompt-items' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +const ADMITTED = { accepted: true } as const + +type ClaudeJournalPrompt = { + identity: AgentJournalItemIdentity + body: AgentJournalApprovalItem | AgentJournalQuestionItem +} + +type ClaudeJournalPromptEntry = { + items: ClaudeJournalPrompt[] + cancellationPending: boolean +} + +function cancelledPromptBody( + body: AgentJournalApprovalItem | AgentJournalQuestionItem +): AgentJournalApprovalItem | AgentJournalQuestionItem { + const cancelled = cancelledJournalPromptBody(body) + if (!cancelled) { + throw new Error('Claude prompt body is not cancellable') + } + return cancelled +} + +export class ClaudeJournalPrompts { + private readonly items = new Map() + private pendingCancellationTotal = 0 + + get size(): number { + return this.items.size + } + + get pendingCancellationCount(): number { + return this.pendingCancellationTotal + } + + constructor( + private readonly deps: { + sink: StructuredAgentSessionEventSink + bindPromptItemId?: (journalItemId: string, promptKey: string, questionId?: string) => void + questionItems?: (input: { + sessionId: string + prompt: Extract['prompt'] + }) => ClaudeQuestionItem[] + } + ) {} + + handle(event: Extract): void { + const items: ClaudeJournalPrompt[] = [] + if (event.prompt.kind === 'question') { + for (const question of (this.deps.questionItems ?? claudeQuestionItems)({ + sessionId: event.sessionId, + prompt: event.prompt + })) { + items.push(question) + this.deps.sink.appendItem(question.identity, question.body) + this.deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) + } + } else { + const identity = claudePromptIdentity({ + sessionId: event.sessionId, + promptKey: event.prompt.promptKey + }) + const body = claudeApprovalItem(event.prompt) + items.push({ identity, body }) + this.deps.sink.appendItem(identity, body) + this.deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) + } + this.deletePrompt(event.prompt.promptKey) + this.items.set(event.prompt.promptKey, { items, cancellationPending: false }) + this.deps.sink.publish() + } + + private admitCancellation(promptKey: string): StructuredAgentSessionSinkAdmission { + const items = this.items.get(promptKey)?.items ?? [] + if (items.length === 0) { + return ADMITTED + } + const mutations = items.map(({ identity, body }) => ({ + kind: 'item' as const, + identity, + body: cancelledPromptBody(body) + })) + let admission: StructuredAgentSessionSinkAdmission + if (this.deps.sink.tryAppendLifecycleBatch) { + admission = this.deps.sink.tryAppendLifecycleBatch( + `prompt-cancelled:${encodeURIComponent(promptKey)}`, + mutations, + { lifecycle: true } + ) + } else if (this.deps.sink.appendLifecycleBatch) { + admission = + this.deps.sink.appendLifecycleBatch( + `prompt-cancelled:${encodeURIComponent(promptKey)}`, + mutations, + { lifecycle: true } + ) ?? ADMITTED + } else if (items.length === 1) { + const item = items[0] + if (!item) { + return ADMITTED + } + const body = cancelledPromptBody(item.body) + admission = this.deps.sink.tryAppendItem + ? this.deps.sink.tryAppendItem(item.identity, body, { lifecycle: true }) + : (this.deps.sink.appendItem(item.identity, body, { lifecycle: true }), ADMITTED) + } else { + return { accepted: false, reason: 'failed' } + } + if (!admission.accepted) { + return admission + } + const published = this.deps.sink.tryPublish + ? this.deps.sink.tryPublish({ lifecycle: true }) + : (this.deps.sink.publish({ lifecycle: true }), ADMITTED) + if (published.accepted) { + this.deletePrompt(promptKey) + } + return published + } + + private deletePrompt(promptKey: string): void { + const entry = this.items.get(promptKey) + if (entry?.cancellationPending) { + this.pendingCancellationTotal -= 1 + } + this.items.delete(promptKey) + } + + private setCancellationPending(entry: ClaudeJournalPromptEntry, pending: boolean): void { + if (entry.cancellationPending === pending) { + return + } + entry.cancellationPending = pending + this.pendingCancellationTotal += pending ? 1 : -1 + } + + cancel(promptKey: string): StructuredAgentSessionSinkAdmission { + const admission = this.admitCancellation(promptKey) + const entry = this.items.get(promptKey) + if (entry) { + this.setCancellationPending(entry, !admission.accepted && admission.reason === 'backpressure') + } + return admission + } + + retryPendingCancellations(): void { + if (this.pendingCancellationTotal === 0) { + return + } + for (const [promptKey, entry] of this.items) { + if (!entry.cancellationPending) { + continue + } + const admission = this.admitCancellation(promptKey) + if (!admission.accepted && admission.reason === 'backpressure') { + return + } + const retained = this.items.get(promptKey) + if (retained) { + this.setCancellationPending(retained, false) + } + } + } + + resolve(promptKey: string): void { + this.deletePrompt(promptKey) + } + + clear(): void { + this.items.clear() + this.pendingCancellationTotal = 0 + } +} diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 65be68bb05f..d9b8736c955 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -309,6 +309,53 @@ describe('Claude structured journal translation', () => { expect(providerFrameKinds(items)).toEqual([]) }) + it('restores a cancelled prompt as terminal history after reopening the journal', async () => { + const journal = await openAgentSessionJournal({ + identity: JOURNAL_IDENTITY, + journalDir: journalRoot, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink }) + const approval = prompt({ + requestId: 'permission-1', + promptKey: 'permission-1', + toolUseId: 'tool-1', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + questionIds: [] + }) + + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt: approval }) + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: approval.promptKey + }) + await expect(deferred.drained()).resolves.toEqual({ ok: true }) + deferred.close() + await journal.close() + + const reopened = await openAgentSessionJournal({ + identity: JOURNAL_IDENTITY, + journalDir: journalRoot, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-2' + }) + expect(reopened.snapshot().items).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ + kind: 'approval', + resolution: expect.objectContaining({ state: 'cancelled' }) + }) + }) + ]) + await reopened.close() + }) + it('settles result frames, empty thinking and string user replays without painting a row', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) @@ -799,7 +846,11 @@ describe('Claude structured journal translation', () => { sessionId: 'orca-session', promptKey: 'questions-1' }) - expect(state.tombstones).toHaveLength(1) + expect(state.items.at(-1)?.body).toMatchObject({ + kind: 'question', + resolution: { state: 'cancelled' } + }) + expect(state.tombstones).toHaveLength(0) }) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 702ff5f6b26..216e760315c 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -1,4 +1,3 @@ -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -21,7 +20,6 @@ import { readClaudeMessageEnvelope, type ClaudeToolUse } from './claude-structured-item-translation' -import { journalClaudePrompt } from './claude-prompt-journaling' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { @@ -48,6 +46,7 @@ import { type ClaudeCurrentTurn, type ClaudeTurnEnd } from './claude-turn-lifecycle-item' +import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' export type ClaudeJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink @@ -59,6 +58,7 @@ export type ClaudeJournalTranslatorDeps = { export type ClaudeJournalTranslator = { handle: (event: ClaudeStructuredSessionEvent) => void + journalPrompts: Pick flush: () => void /** Streamed blocks still awaiting a final frame. A settled turn leaves none. */ readonly pendingStreamedBlocks: number @@ -84,7 +84,7 @@ export function createClaudeJournalTranslator( deps: ClaudeJournalTranslatorDeps ): ClaudeJournalTranslator { const tools = new Map() - const promptItems = new Map() + const prompts = new ClaudeJournalPrompts(deps) const streamedBlocks = createClaudeStreamedBlockRegistry() let currentTurn: ClaudeCurrentTurn | null = null /** Provider output may not reopen a turn after the session ended or a turn @@ -256,6 +256,7 @@ export function createClaudeJournalTranslator( return { handle: (event) => { if (event.type === 'ended') { + prompts.retryPendingCancellations() streamedText.flush() // No event will ever settle a child once the provider is gone. subagents.settleSession() @@ -278,13 +279,10 @@ export function createClaudeJournalTranslator( } streamedText.flush() if (event.type === 'prompt') { - journalClaudePrompt({ ...deps, promptItems }, event) + prompts.handle(event) } else if (event.type === 'prompt-cancelled') { - for (const identity of promptItems.get(event.promptKey) ?? []) { - deps.sink.appendTombstone(identity) - } - promptItems.delete(event.promptKey) - deps.sink.publish() + prompts.retryPendingCancellations() + prompts.cancel(event.promptKey) } else if (event.type === 'message' && event.message.type === 'result') { // Every turn this translator opens is root by construction, so a nested // result settles the child that produced it and never the turn. The @@ -292,6 +290,7 @@ export function createClaudeJournalTranslator( // it ends no turn. const settlesTurn = isRootClaudeFrame(event.message) if (settlesTurn) { + prompts.retryPendingCancellations() // The turn is over however it ended, so a foreground child still // reported as working will never be settled by an event. // A turn that failed, or that the user stopped, is not resumed by @@ -335,6 +334,7 @@ export function createClaudeJournalTranslator( publishActivity(event.kind, event.payload) } }, + journalPrompts: prompts, flush: streamedText.flush, get pendingStreamedBlocks() { return streamedText.pending @@ -342,7 +342,7 @@ export function createClaudeJournalTranslator( dispose: () => { streamedText.dispose() tools.clear() - promptItems.clear() + prompts.clear() streamedBlocks.clear() subagents.dispose() } diff --git a/src/main/claude/claude-structured-prompt-items.test.ts b/src/main/claude/claude-structured-prompt-items.test.ts index 79916d6a507..eec4ee74c9c 100644 --- a/src/main/claude/claude-structured-prompt-items.test.ts +++ b/src/main/claude/claude-structured-prompt-items.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' +import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' +import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } from '../native-chat/agent-session-journal/journal-row-schema' import { claudeQuestionItems } from './claude-structured-prompt-items' import { applyClaudePromptAnswer, @@ -9,6 +11,46 @@ import { } from './claude-structured-prompt-replies' describe('Claude structured question addressing', () => { + it('bounds a valid grouped question before cancellation enters a lifecycle batch', () => { + const oversized = 'large prompt text '.repeat(40_000) + const questions = Array.from({ length: 4 }, (_, questionIndex) => ({ + question: `${questionIndex}:${oversized}`, + header: oversized, + options: Array.from({ length: 4 }, (_, optionIndex) => ({ + label: `${optionIndex}:${oversized}`, + description: oversized + })) + })) + const prompt: ClaudePendingPrompt = { + requestId: 'oversized-question', + promptKey: 'oversized-question', + toolUseId: 'tool-oversized', + toolName: 'AskUserQuestion', + kind: 'question', + input: { questions }, + suggestions: [], + questionIds: questions.map((question) => question.question), + answers: new Map(), + settle: () => {} + } + + const body = claudeQuestionItems({ sessionId: 'session-1', prompt })[0]?.body + if (!body) { + throw new Error('expected grouped question body') + } + const cancelled = cancelledJournalPromptBody(body) + if (!cancelled) { + throw new Error('expected cancellable grouped question body') + } + + expect(body.questions).toHaveLength(4) + expect(body.questions?.[0]?.question).toContain('[Orca: output truncated') + expect(body.questions?.[0]?.options[0]?.description).toContain('[Orca: output truncated') + expect(Buffer.byteLength(JSON.stringify(cancelled), 'utf8') + 4_096).toBeLessThan( + MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + ) + }) + it('keeps wire IDs bounded while returning the original question and choice', () => { const questionId = 'Which option? '.repeat(100) const label = 'A detailed choice '.repeat(100) diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index 3bdf8ab6091..25b307bd809 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -9,6 +9,7 @@ import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { boundJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { claudeRecord, claudeText } from './claude-structured-item-translation' import { CLAUDE_APPROVAL_DECISIONS, @@ -119,7 +120,7 @@ export function claudeQuestionItems(input: { sessionId: input.sessionId, promptKey: input.prompt.promptKey }), - body: { + body: boundJournalPromptBody({ kind: 'question', question: legacyCompatible ? first.question @@ -128,7 +129,7 @@ export function claudeQuestionItems(input: { ...(legacyCompatible ? { freeTextQuestionId: first.freeTextQuestionId } : {}), questions, resolution: { ...PENDING } - } + }) } ] } diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts new file mode 100644 index 00000000000..7a06a58e881 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -0,0 +1,766 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionAppendOptions, + StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' +import { claudeQuestionItems } from './claude-structured-prompt-items' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + acquired, + adapterFor, + fakeClaude, + identityFor, + invokeCanUseTool +} from './claude-structured-session-test-support' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((finish) => { + resolve = finish + }) + return { promise, resolve } +} + +function lifecycleRecorder(acceptPromptCancellation = true): { + sink: StructuredAgentSessionEventSink + bodies: Map + tombstones: Set + order: string[] +} { + const bodies = new Map() + const tombstones = new Set() + const order: string[] = [] + const appendTombstone = ( + identity: Parameters[0], + options?: StructuredAgentSessionAppendOptions + ): void => { + const key = agentJournalItemKey(identity) + bodies.delete(key) + tombstones.add(key) + if (options?.lifecycle === true) { + order.push('prompt-lifecycle') + } + } + const appendItem = ( + identity: Parameters[0], + body: Parameters[1], + options?: StructuredAgentSessionAppendOptions + ): void => { + bodies.set(agentJournalItemKey(identity), body) + if (options?.lifecycle === true) { + order.push('prompt-lifecycle') + } + } + const sink: StructuredAgentSessionEventSink = { + appendItem, + appendTombstone, + tryAppendTombstone: (identity, options) => { + if (!acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + appendTombstone(identity, options) + return { accepted: true } + }, + tryAppendLifecycleBatch: (_settlementId, mutations, options) => { + if (!acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + for (const mutation of mutations) { + if (mutation.kind === 'tombstone') { + appendTombstone(mutation.identity, options) + } else { + appendItem(mutation.identity, mutation.body, options) + } + } + return { accepted: true } + }, + publish: (_options?: StructuredAgentSessionAppendOptions) => {}, + tryPublish: () => ({ accepted: true }) + } + return { sink, bodies, tombstones, order } +} + +async function startTurn( + adapter: Awaited>, + turnId = 'turn-1' +): Promise { + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: `client-${turnId}`, + body: USER_MESSAGE, + fence: 7 + }) +} + +describe('Claude live prompt ownership', () => { + it('lets an answer hold the callback claim through its journal commit', async () => { + const claude = fakeClaude({ replayUuid: 'turn-1' }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' } + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + const commitGate = deferred() + const commitStarted = vi.fn() + + const answer = adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit: async () => { + expect(answered.settled()).toBe(false) + commitStarted() + await commitGate.promise + } + }) + await vi.waitFor(() => expect(commitStarted).toHaveBeenCalledOnce()) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + + commitGate.resolve() + await answer + await expect(answered.promise).resolves.toMatchObject({ + behavior: 'allow', + toolUseID: 'tool-1' + }) + }) + + it('lets prompt cancellation win and waits for SDK abort cleanup', async () => { + const interruptGate = deferred() + const controller = new AbortController() + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { interrupt: () => interruptGate.promise } + }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + + let cancellationSettled = false + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + .finally(() => { + cancellationSettled = true + }) + await vi.waitFor(() => expect(claude.connections[0]?.calls.at(-1)?.subtype).toBe('interrupt')) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + + interruptGate.resolve() + await Promise.resolve() + expect(cancellationSettled).toBe(false) + expect(answered.settled()).toBe(false) + controller.abort() + await expect(cancellation).resolves.toEqual({ cancelled: true }) + await expect(answered.promise).resolves.toBeNull() + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(controller.signal.aborted).toBe(true) + expect(commit).not.toHaveBeenCalled() + }) + + it('cancels an owned prompt after another dispatch queues behind its turn', async () => { + const controller = new AbortController() + let queuedUuid = '' + const claude = fakeClaude({ + replayUuids: ['turn-1', null], + capabilities: ['interrupt_cancel_queued_v1'], + routes: { + interrupt: () => { + controller.abort() + return { still_queued: [], cancelled: [queuedUuid] } + } + } + }) + const lateSettlements: unknown[] = [] + const adapter = await acquired(claude, {}, [], (settlement) => lateSettlements.push(settlement)) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-queued', 'tool-queued', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-queued') + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'queued-message', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + const sentUuid = connection.sent.at(-1)?.uuid + if (typeof sentUuid !== 'string') { + throw new Error('expected queued dispatch uuid') + } + queuedUuid = sentUuid + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: true }) + await expect(answered.promise).resolves.toBeNull() + expect(connection.calls).toContainEqual({ + subtype: 'interrupt', + params: { cancelQueued: true } + }) + expect(lateSettlements).toContainEqual({ + sessionId: 'session-1', + clientMessageId: 'queued-message', + state: 'rejected', + reason: 'provider_cancelled_before_start' + }) + }) + + it('does not interrupt a queued turn when the CLI cannot cancel queued messages', async () => { + const claude = fakeClaude({ replayUuids: ['turn-1', null] }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const controller = new AbortController() + const answered = invokeCanUseTool(connection, 'Bash', 'permission-legacy', 'tool-legacy', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-legacy') + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'queued-message', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + controller.abort() + await expect(answered.promise).resolves.toBeNull() + }) + + it('does not interrupt a newer active turn through a stale prompt callback', async () => { + const claude = fakeClaude({ replayUuids: ['turn-1', 'turn-2'] }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const controller = new AbortController() + const answered = invokeCanUseTool(connection, 'Bash', 'permission-stale', 'tool-stale', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-stale') + await startTurn(adapter, 'turn-2') + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + expect(answered.settled()).toBe(false) + controller.abort() + await expect(answered.promise).resolves.toBeNull() + }) + + it('drops resolved prompt bodies instead of retaining them for the session lifetime', () => { + const prompts = new ClaudeJournalPrompts({ sink: lifecycleRecorder().sink }) + + for (let index = 0; index < 128; index += 1) { + const promptKey = `resolved-${index}` + prompts.handle({ + type: 'prompt', + sessionId: 'session-1', + prompt: { + requestId: promptKey, + promptKey, + toolUseId: `tool-${index}`, + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: vi.fn() + } + }) + prompts.resolve(promptKey) + } + + expect(prompts.size).toBe(0) + }) + + it('releases the callback claim after a failed interrupt', async () => { + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { + interrupt: () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + } + }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' } + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 7, + commit: async () => undefined + }) + await expect(answered.promise).resolves.toMatchObject({ + behavior: 'allow', + toolUseID: 'tool-1' + }) + }) + + it('enqueues terminal prompt state before a confirmed cancellation resolves', async () => { + const controller = new AbortController() + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { interrupt: () => controller.abort() } + }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + .then((result) => { + recorded.order.push('resolved') + return result + }) + + await expect(cancellation).resolves.toEqual({ cancelled: true }) + await expect(answered.promise).resolves.toBeNull() + if (!promptItemId) { + throw new Error('expected a recorded prompt item') + } + expect(recorded.order).toEqual(['prompt-lifecycle', 'resolved']) + expect( + [...recorded.bodies.values()].some( + (body) => + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' + ) + ).toBe(false) + expect(recorded.bodies.get(promptItemId)).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect( + [...recorded.bodies.values()].some( + (body) => readAgentJournalTurn(body)?.state === 'interrupted' + ) + ).toBe(false) + + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'error_during_execution', + uuid: 'result-1', + session_id: PROVIDER_SESSION_ID, + is_error: true, + terminal_reason: 'aborted_tools', + errors: [], + duration_ms: 654 + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 654 + }) + + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'success', + uuid: 'result-duplicate', + session_id: PROVIDER_SESSION_ID, + is_error: false, + terminal_reason: 'completed', + duration_ms: 999 + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 654 + }) + + expect(controller.signal.aborted).toBe(true) + expect(recorded.tombstones).toHaveLength(0) + }) + + it('does not synthesize terminal lifecycle for ordinary Stop', async () => { + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(fakeClaude({ replayUuid: 'turn-1' }), {}, events) + await startTurn(adapter) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(events.some((event) => event.type === 'prompt-cancelled')).toBe(false) + expect( + events.some((event) => event.type === 'message' && event.message.type === 'result') + ).toBe(false) + }) + + it('does not report success or release the claim when prompt lifecycle admission fails', async () => { + const controller = new AbortController() + const claude = fakeClaude({ + replayUuid: 'turn-1', + routes: { interrupt: () => controller.abort() } + }) + const recorded = lifecycleRecorder(false) + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + signal: controller.signal + }) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Claude prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).rejects.toThrow(/lifecycle was not admitted/) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'allow', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('checks the bound item, turn, fence, and current acquisition without callback revival', async () => { + const claude = fakeClaude({ replayUuid: 'turn-1' }) + const adapter = await acquired(claude) + await startTurn(adapter) + const connection = claude.connections[0] + if (!connection) { + throw new Error('expected Claude connection') + } + const answered = invokeCanUseTool(connection, 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' } + }) + adapter.bindPromptItemId('session-1', 'journal-prompt', 'permission-1') + + for (const input of [ + { turnId: 'turn-1', fence: 7, itemId: 'other-item' }, + { turnId: 'turn-2', fence: 7, itemId: 'journal-prompt' }, + { turnId: 'turn-1', fence: 6, itemId: 'journal-prompt' } + ]) { + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: input.turnId, + fence: input.fence, + prompt: { itemId: input.itemId } + }) + ).resolves.toEqual({ cancelled: false }) + } + expect(claude.connections[0]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + + await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + await expect(answered.promise).resolves.toBeNull() + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 8, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'allow', + fence: 8, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + expect(claude.connections[1]?.calls.some((call) => call.subtype === 'interrupt')).toBe(false) + }) + + it('rejects a grouped prompt batch without partially revising its first row', () => { + const tombstones: string[] = [] + const appendTombstone = vi.fn( + (identity: Parameters[0]) => { + tombstones.push(agentJournalItemKey(identity)) + } + ) + let rowAdmission = 0 + const tryAppendTombstone = vi.fn( + (identity: Parameters[0]) => { + rowAdmission += 1 + if (rowAdmission === 2) { + return { accepted: false as const, reason: 'backpressure' as const } + } + appendTombstone(identity) + return { accepted: true as const } + } + ) + const tryAppendLifecycleBatch = vi.fn( + ( + _settlementId: string, + mutations: Parameters< + NonNullable + >[1] + ) => { + expect(mutations[1]).toMatchObject({ + kind: 'item', + body: { resolution: { state: 'cancelled' } } + }) + return { accepted: false as const, reason: 'backpressure' as const } + } + ) + const prompts = new ClaudeJournalPrompts({ + sink: { + appendItem: () => {}, + appendTombstone, + tryAppendTombstone, + tryAppendLifecycleBatch, + publish: () => {} + }, + questionItems: (input) => { + const item = claudeQuestionItems(input)[0] + return item + ? [ + { + ...item, + identity: { provider: 'orca', clientMessageId: 'group:first' } + }, + { + ...item, + identity: { provider: 'orca', clientMessageId: 'group:second' } + } + ] + : [] + } + }) + const prompt: ClaudePendingPrompt = { + requestId: 'grouped-request', + promptKey: 'grouped-request', + toolUseId: 'tool-grouped', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { question: 'First?', options: [{ label: 'Yes' }] }, + { question: 'Second?', options: [{ label: 'No' }] } + ] + }, + suggestions: [], + questionIds: ['First?', 'Second?'], + answers: new Map(), + settle: vi.fn() + } + prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) + + expect(prompts.cancel(prompt.promptKey)).toEqual({ + accepted: false, + reason: 'backpressure' + }) + expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce() + expect(tryAppendTombstone).not.toHaveBeenCalled() + expect(tombstones).toEqual([]) + }) + + it('keeps every backpressured prompt cancellation retry in its owned entry', () => { + let backpressured = true + let lifecycleAttempts = 0 + const prompts = new ClaudeJournalPrompts({ + sink: { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendLifecycleBatch: () => { + lifecycleAttempts += 1 + return backpressured ? { accepted: false, reason: 'backpressure' } : { accepted: true } + } + } + }) + const registerCancellation = (index: number): void => { + const promptKey = `permission-${index}` + const prompt: ClaudePendingPrompt = { + requestId: promptKey, + promptKey, + toolUseId: `tool-${index}`, + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: vi.fn() + } + prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) + prompts.cancel(promptKey) + } + + registerCancellation(0) + prompts.cancel('permission-0') + expect(prompts.pendingCancellationCount).toBe(1) + for (let index = 1; index < 65; index += 1) { + registerCancellation(index) + } + expect(prompts.pendingCancellationCount).toBe(65) + + backpressured = false + const attemptsBeforeRecovery = lifecycleAttempts + prompts.retryPendingCancellations() + expect(lifecycleAttempts - attemptsBeforeRecovery).toBe(65) + expect(prompts.pendingCancellationCount).toBe(0) + expect(prompts.size).toBe(0) + const attemptsAfterRecovery = lifecycleAttempts + prompts.retryPendingCancellations() + expect(lifecycleAttempts).toBe(attemptsAfterRecovery) + + backpressured = true + registerCancellation(65) + expect(prompts.pendingCancellationCount).toBe(1) + prompts.resolve('permission-65') + expect(prompts.pendingCancellationCount).toBe(0) + registerCancellation(66) + prompts.clear() + expect(prompts.pendingCancellationCount).toBe(0) + expect(prompts.size).toBe(0) + }) +}) diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts new file mode 100644 index 00000000000..1dd73f23552 --- /dev/null +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -0,0 +1,143 @@ +import { + AgentSessionPromptUnavailableError, + type StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' +import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' +import { + answerClaudePrompt, + cancelClaudeTurn, + supportsClaudeQueuedInterruptCancellation +} from './claude-structured-control-actions' +import type { ClaudeLateDispatchSettlement } from './claude-structured-dispatch' +import type { ClaudeSession } from './claude-structured-session-state' + +type CancelInput = Parameters[0] +type AnswerInput = Parameters[0] + +export function admitClaudePromptCancellation(session: ClaudeSession, promptKey: string): boolean { + const admission = session.translator?.journalPrompts.cancel(promptKey) + return admission?.accepted ?? true +} + +function waitForClaudePromptCancellation( + observed: Promise, + timeoutMs = CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS +): Promise { + let timer: ReturnType | null = null + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('Claude prompt cancellation abort was not observed')), + timeoutMs + ) + timer.unref?.() + }) + return Promise.race([observed, deadline]).finally(() => { + if (timer) { + clearTimeout(timer) + } + }) +} + +function requireSession(sessions: Map, sessionId: string): ClaudeSession { + const session = sessions.get(sessionId) + if (!session) { + throw new Error(`no live claude stream-json session for ${sessionId}`) + } + return session +} + +export async function cancelClaudeStructuredTurn(input: { + request: CancelInput + sessions: Map + compactions: StructuredSessionCompaction + timeoutMs?: number + admitPromptCancellation: (session: ClaudeSession, promptKey: string) => boolean + onDispatchSettledLate?: ClaudeLateDispatchSettlement +}): Promise<{ cancelled: boolean }> { + const { request, sessions, compactions, timeoutMs } = input + const session = requireSession(sessions, request.sessionId) + const acquisitionGeneration = session.acquisitionGeneration + const prompt = request.prompt + if (prompt && session.fence !== request.fence) { + return { cancelled: false } + } + const claim = prompt ? session.prompts.claimBound(prompt.itemId, request.turnId) : null + if (prompt && !claim) { + return { cancelled: false } + } + const cancellationObserved = claim ? session.prompts.observeCancellation(claim) : null + if (claim && !cancellationObserved) { + session.prompts.releaseClaim(claim) + return { cancelled: false } + } + const isCurrent = (): boolean => + sessions.get(request.sessionId) === session && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration && + (claim && prompt + ? session.activeTurnId === request.turnId && + session.prompts.ownsBoundClaim(claim, prompt.itemId, request.turnId) && + (session.activeTurnSequence === session.dispatchSequence || + supportsClaudeQueuedInterruptCancellation(session)) + : compactions.ownsTurn(request.sessionId, request.turnId) || + (session.activeTurnId === undefined + ? session.dispatchSequence === 0 + : session.activeTurnId === request.turnId && + session.activeTurnSequence === session.dispatchSequence)) + let interruptConfirmed = false + try { + const result = await cancelClaudeTurn( + session, + timeoutMs, + isCurrent, + input.onDispatchSettledLate + ) + if (result.cancelled && claim && cancellationObserved) { + interruptConfirmed = true + await waitForClaudePromptCancellation(cancellationObserved, timeoutMs) + if (!input.admitPromptCancellation(session, claim.found.prompt.promptKey)) { + throw new Error(`Claude prompt cancellation lifecycle was not admitted for ${claim.itemId}`) + } + } else if (claim) { + session.prompts.releaseClaim(claim) + } + return result + } catch (error) { + if (claim && !interruptConfirmed) { + session.prompts.releaseClaim(claim) + } + throw error + } +} + +export async function answerClaudeStructuredPrompt(input: { + request: AnswerInput + sessions: Map +}): Promise { + const { request, sessions } = input + const session = sessions.get(request.sessionId) + if (!session || session.fence !== request.fence) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + const acquisitionGeneration = session.acquisitionGeneration + const claim = session.prompts.claim(request.itemId, request.kind) + if (!claim) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + try { + await request.commit() + if ( + sessions.get(request.sessionId) !== session || + session.fence !== request.fence || + session.acquisitionGeneration !== acquisitionGeneration || + !session.prompts.ownsClaim(claim) + ) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + await answerClaudePrompt(session, claim, request.optionId) + } catch (error) { + session.prompts.releaseClaim(claim) + throw error + } +} diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index deec74b7308..5a6bc19b9a8 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -1,48 +1,24 @@ +import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' import { decodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer' +import { + claudePromptQuestions, + isClaudePromptRecord, + readClaudePromptString, + type ClaudePendingPrompt +} from './claude-prompt-registry' +export { + ClaudePromptRegistry, + type ClaudePendingPrompt, + type ClaudePromptClaim, + type ClaudePromptRegistration, + type ClaudePromptSettle +} from './claude-prompt-registry' export const CLAUDE_APPROVAL_DECISIONS = ['allow', 'allowForSession', 'deny', 'cancel'] as const export type ClaudeApprovalDecision = (typeof CLAUDE_APPROVAL_DECISIONS)[number] -/** Settles the SDK's `canUseTool` promise; `null` is the SDK's "no response written" sentinel. */ -export type ClaudePromptSettle = (response: Record | null) => void - -export type ClaudePendingPrompt = { - requestId: string - promptKey: string - toolUseId: string - toolName: string - kind: 'approval' | 'question' - input: Record - suggestions: unknown[] - questionIds: readonly string[] - answers: Map - settle: ClaudePromptSettle -} - -export type ClaudePromptRegistration = { - requestId: string - toolName: string - toolUseId: string - input: Record - suggestions: unknown[] - settle: ClaudePromptSettle -} - -type PromptBinding = { - address: string - questionId?: string -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function readString(value: unknown): string | null { - return typeof value === 'string' && value.trim().length > 0 ? value : null -} - -function questionsFrom(input: Record): Record[] { - return Array.isArray(input.questions) ? input.questions.filter(isRecord) : [] +function isClaudeApprovalDecision(optionId: string): optionId is ClaudeApprovalDecision { + return CLAUDE_APPROVAL_DECISIONS.some((decision) => decision === optionId) } function questionIdFromAddress(prompt: ClaudePendingPrompt, address: string): string | null { @@ -62,10 +38,10 @@ function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionI } const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer) const optionIndex = choice ? Number(choice[1]) - 1 : -1 - const question = questionsFrom(prompt.input)[questionIndex] + const question = claudePromptQuestions(prompt.input)[questionIndex] const options = Array.isArray(question?.options) ? question.options : [] const option = options[optionIndex] - const label = isRecord(option) ? readString(option.label) : null + const label = isClaudePromptRecord(option) ? readClaudePromptString(option.label) : null if (decoded.questionId === `q${questionIndex + 1}` && label) { return label } @@ -73,17 +49,14 @@ function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionI return decoded.answer } const legacyChoice = options.some( - (candidate) => isRecord(candidate) && readString(candidate.label) === decoded.answer + (candidate) => + isClaudePromptRecord(candidate) && readClaudePromptString(candidate.label) === decoded.answer ) return decoded.questionId === questionId && (legacyChoice || decoded.answer.trim().length > 0) ? decoded.answer : optionId } -function questionId(question: Record, index: number): string { - return readString(question.question) ?? readString(question.header) ?? `question-${index + 1}` -} - export function encodeClaudeQuestionOptionId(questionId: string, answer: string): string { return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` } @@ -105,88 +78,11 @@ export function decodeClaudeQuestionOptionId( } } -export class ClaudePromptRegistry { - private readonly prompts = new Map() - private readonly journalBindings = new Map() - - register(registration: ClaudePromptRegistration): ClaudePendingPrompt | null { - const toolUseId = readString(registration.toolUseId) - const toolName = readString(registration.toolName) - const input = isRecord(registration.input) ? registration.input : null - if (!toolUseId || !toolName || !input) { - return null - } - const questions = toolName === 'AskUserQuestion' ? questionsFrom(input) : [] - const prompt: ClaudePendingPrompt = { - requestId: registration.requestId, - promptKey: registration.requestId, - toolUseId, - toolName, - kind: questions.length > 0 ? 'question' : 'approval', - input, - suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [], - questionIds: questions.map(questionId), - answers: new Map(), - settle: registration.settle - } - this.prompts.set(prompt.promptKey, prompt) - return prompt - } - - /** True only if the prompt was still pending; lets an abort and an answer race settle once. */ - forgetIfPending(prompt: ClaudePendingPrompt): boolean { - if (!this.prompts.has(prompt.promptKey)) { - return false - } - this.forget(prompt) - return true - } - - bindJournalItemId(journalItemId: string, promptKey: string, questionIdForItem?: string): void { - this.journalBindings.set(journalItemId, { - address: promptKey, - ...(questionIdForItem ? { questionId: questionIdForItem } : {}) - }) - } - - find(itemId: string): { prompt: ClaudePendingPrompt; questionId?: string } | null { - const binding = this.journalBindings.get(itemId) - const prompt = this.prompts.get(binding?.address ?? itemId) - return prompt - ? { prompt, ...(binding?.questionId ? { questionId: binding.questionId } : {}) } - : null - } - - cancel(requestId: string): ClaudePendingPrompt | null { - const prompt = this.prompts.get(requestId) ?? null - if (prompt) { - this.forget(prompt) - } - return prompt - } - - forget(prompt: ClaudePendingPrompt): void { - this.prompts.delete(prompt.promptKey) - for (const [itemId, binding] of this.journalBindings) { - if (binding.address === prompt.promptKey) { - this.journalBindings.delete(itemId) - } - } - } - - clear(): ClaudePendingPrompt[] { - const pending = [...this.prompts.values()] - this.prompts.clear() - this.journalBindings.clear() - return pending - } -} - -function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): Record { - if (!(CLAUDE_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { +function approvalResponse(prompt: ClaudePendingPrompt, optionId: string): PermissionResult { + if (!isClaudeApprovalDecision(optionId)) { throw new Error(`${optionId} is not a Claude approval decision`) } - const decision = optionId as ClaudeApprovalDecision + const decision = optionId if (decision === 'allow' || decision === 'allowForSession') { return { behavior: 'allow', @@ -209,7 +105,7 @@ function questionResponse( prompt: ClaudePendingPrompt, optionId: string, boundQuestionId?: string -): Record | null { +): PermissionResult | null { const decoded = decodeClaudeQuestionOptionId(optionId) const decodedQuestionId = decoded ? (questionIdFromAddress(prompt, decoded.questionId) ?? @@ -229,7 +125,11 @@ function questionResponse( } const answers: Record = {} for (const id of prompt.questionIds) { - answers[id] = prompt.answers.get(id) as string + const answer = prompt.answers.get(id) + if (answer === undefined) { + return null + } + answers[id] = answer } return { behavior: 'allow', @@ -241,21 +141,21 @@ function questionResponse( function groupedQuestionResponse( prompt: ClaudePendingPrompt, optionId: string -): Record | null { +): PermissionResult | null { const grouped = decodeAgentSessionQuestionAnswers(optionId) if (!grouped) { return null } - const questions = questionsFrom(prompt.input) + const questions = claudePromptQuestions(prompt.input) if (grouped.length !== prompt.questionIds.length) { throw new Error(`Grouped answer does not match Claude prompt ${prompt.promptKey}`) } const answers: Record = {} for (let index = 0; index < questions.length; index += 1) { - const question = questions[index]! + const question = questions[index] const providerQuestionId = prompt.questionIds[index] const answer = grouped.find((entry) => entry.questionId === `q${index + 1}`) - if (!providerQuestionId || !answer) { + if (!question || !providerQuestionId || !answer) { throw new Error(`Grouped answer does not name question ${index + 1}`) } const selected = answer.optionIds.map((selectedId) => @@ -286,7 +186,7 @@ function groupedQuestionResponse( export function applyClaudePromptAnswer( found: { prompt: ClaudePendingPrompt; questionId?: string }, optionId: string -): Record | null { +): PermissionResult | null { if (found.prompt.kind === 'approval') { return approvalResponse(found.prompt, optionId) } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index ec7fc85dadb..8870a0e1daa 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -142,6 +142,7 @@ export async function acquireClaudeSession({ const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, prompts, + currentTurnId: () => liveSession?.activeTurnId ?? null, emit: (event) => callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) }) diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 20be62f7997..5593b07d329 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -676,7 +676,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-approval', kind: 'approval', optionId: 'allowForSession', - fence: 7 + fence: 7, + commit: async () => undefined }) // The answer resolves the SDK's own callback promise; the SDK writes the wire response. await expect(answered.promise).resolves.toEqual({ @@ -712,7 +713,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-q1', kind: 'question', optionId: encodeClaudeQuestionOptionId('Library?', 'Luxon'), - fence: 7 + fence: 7, + commit: async () => undefined }) await tick() expect(answered.settled()).toBe(false) @@ -721,7 +723,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-q2', kind: 'question', optionId: encodeClaudeQuestionOptionId('Ship now?', 'Yes'), - fence: 7 + fence: 7, + commit: async () => undefined }) await expect(answered.promise).resolves.toMatchObject({ behavior: 'allow', @@ -752,7 +755,8 @@ describe('ClaudeStructuredSessionAdapter prompts', () => { itemId: 'journal-9', kind: 'approval', optionId: 'allow', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow(/no longer waiting/) }) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 44d73c8bd0f..f62541b50e9 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -4,11 +4,7 @@ import type { StructuredAgentSessionAcquireInput, StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { - answerClaudePrompt, - cancelClaudeTurn, - stopClaudeBackgroundTasks -} from './claude-structured-control-actions' +import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' @@ -33,6 +29,11 @@ import { import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window' +import { + admitClaudePromptCancellation, + answerClaudeStructuredPrompt, + cancelClaudeStructuredTurn +} from './claude-structured-prompt-ownership' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' export type { @@ -226,7 +227,13 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda promptKey: string, questionId?: string ): void { - this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey, questionId) + const session = this.sessions.get(sessionId) + session?.prompts.bindJournalItemId( + journalItemId, + promptKey, + questionId, + session.activeTurnId ?? null + ) } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => @@ -235,25 +242,17 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda compact: NonNullable = (input) => compactClaudeSession(this.session(input.sessionId), this.compactions, input) - cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (input) => { - const session = this.session(input.sessionId) - const acquisitionGeneration = session.acquisitionGeneration - return cancelClaudeTurn(session, this.deps.requestTimeoutMs, () => { - // Keep every ownership check adjacent to the provider interrupt. The - // session map check fences a replaced child; the turn check fences a - // delayed cancel after a newer turn was admitted on the same child. - return ( - this.sessions.get(input.sessionId) === session && - session.fence === input.fence && - session.acquisitionGeneration === acquisitionGeneration && - (this.compactions.ownsTurn(input.sessionId, input.turnId) || - (session.activeTurnId === undefined - ? session.dispatchSequence === 0 - : session.activeTurnId === input.turnId && - session.activeTurnSequence === session.dispatchSequence)) - ) + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) => + cancelClaudeStructuredTurn({ + request, + sessions: this.sessions, + compactions: this.compactions, + admitPromptCancellation: (session, promptKey) => + admitClaudePromptCancellation(session, promptKey), + onDispatchSettledLate: (settlement) => + this.deps.onDispatchSettledLate?.({ sessionId: request.sessionId, ...settlement }), + ...(this.deps.requestTimeoutMs === undefined ? {} : { timeoutMs: this.deps.requestTimeoutMs }) }) - } stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'] = (input) => { const session = this.session(input.sessionId) const acquisitionGeneration = session.acquisitionGeneration @@ -278,8 +277,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } readCommands: NonNullable = (sessionId) => this.sessions.get(sessionId)?.commands.commands - answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => - answerClaudePrompt(this.session(input.sessionId), input) + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => + answerClaudeStructuredPrompt({ request, sessions: this.sessions }) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => setClaudeStructuredOption(this.session(input.sessionId), input, this.deps.requestTimeoutMs) readOptions = (input: { sessionId: string; fence: number }) => diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 1fbdcca42c2..30e830af64e 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -62,17 +62,20 @@ export type ClaudeStructuredSessionEvent = observedAt?: number } +export type ClaudeLateDispatchOutcome = + | { + clientMessageId: string + providerIdentity: AgentJournalItemIdentity + } + | { clientMessageId: string; state: 'rejected'; reason: string } + export type ClaudeStructuredSessionAdapterDeps = { resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - /** Direct settlement path for a provider replay; its durable item row also reconciles delivery. */ - onDispatchSettledLate?: (input: { - sessionId: string - clientMessageId: string - providerIdentity: AgentJournalItemIdentity - }) => void + /** Direct settlement path for provider-proven late dispatch outcomes. */ + onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void onBackgroundTasksChanged?: ( sessionId: string, state: AgentSessionBackgroundTaskState | null diff --git a/src/main/codex/codex-prompt-registry-bounds.ts b/src/main/codex/codex-prompt-registry-bounds.ts index f57fbb1e8d1..9c97bf63cd9 100644 --- a/src/main/codex/codex-prompt-registry-bounds.ts +++ b/src/main/codex/codex-prompt-registry-bounds.ts @@ -2,6 +2,7 @@ import { boundPayload, digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' export const CODEX_JOURNAL_PROMPT_ID_COMPONENT_MAX_BYTES = 256 export const CODEX_JOURNAL_PROMPT_OPTION_ID_MAX_BYTES = 1024 @@ -13,7 +14,7 @@ export const CODEX_PROMPT_MAX_ANSWER_BYTES = 64 * 1024 export const MAX_CODEX_PROMPT_REGISTRY_ENTRIES = 128 export const MAX_CODEX_PROMPT_JOURNAL_BINDINGS = 256 export const MAX_CODEX_PROMPT_REGISTRY_BYTES = 4 * 1024 * 1024 -const CODEX_PROMPT_TURN_ID_RESERVED_BYTES = 512 +const CODEX_PROMPT_TURN_ID_RESERVED_BYTES = AGENT_SESSION_ID_MAX_LENGTH * 3 type CodexPromptRegistryEntryBounds = { threadId: string @@ -49,7 +50,7 @@ export function codexPromptTurnIdentity(turnId: string): { turnId: string | null turnIdDigest?: string } { - return Buffer.byteLength(turnId, 'utf8') <= CODEX_PROMPT_TURN_ID_RESERVED_BYTES + return turnId.length <= AGENT_SESSION_ID_MAX_LENGTH ? { turnId } : { turnId: null, turnIdDigest: digestPayload(turnId) } } diff --git a/src/main/codex/codex-prompt-registry.ts b/src/main/codex/codex-prompt-registry.ts new file mode 100644 index 00000000000..c6d0d7f4bef --- /dev/null +++ b/src/main/codex/codex-prompt-registry.ts @@ -0,0 +1,278 @@ +import { + MAX_CODEX_PROMPT_JOURNAL_BINDINGS, + MAX_CODEX_PROMPT_REGISTRY_BYTES, + MAX_CODEX_PROMPT_REGISTRY_ENTRIES, + codexJournalPromptIdPart, + codexPromptMatchesTurn, + codexPromptRegistryEntryBytes, + codexPromptTurnIdentity, + readQuestionIds, + readQuestionOptionAnswers +} from './codex-prompt-registry-bounds' + +export const CODEX_COMMAND_APPROVAL_METHOD = 'item/commandExecution/requestApproval' +export const CODEX_FILE_CHANGE_APPROVAL_METHOD = 'item/fileChange/requestApproval' +export const CODEX_USER_INPUT_METHOD = 'item/tool/requestUserInput' + +export type CodexPendingPrompt = { + requestId: number | string + method: string + threadId: string + turnId: string | null + /** Oversized compatibility turn ids stay comparable without escaping the registry byte cap. */ + turnIdDigest?: string + codexItemId: string + /** One tool item can ask more than once, so approvalId wins over itemId when present. */ + promptKey: string + questionIds: readonly string[] + questionIdAliases: ReadonlyMap + optionAnswers: ReadonlyMap + answers: Map +} + +export type CodexPromptClaim = { + readonly itemId: string + readonly prompt: CodexPendingPrompt +} + +function readString(params: unknown, key: string): string | null { + if (typeof params !== 'object' || params === null) { + return null + } + const value = Reflect.get(params, key) + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function isCodexPromptMethod(method: string): boolean { + return ( + method === CODEX_COMMAND_APPROVAL_METHOD || + method === CODEX_FILE_CHANGE_APPROVAL_METHOD || + method === CODEX_USER_INPUT_METHOD + ) +} + +/** Session-local callback ownership; none of this state is reconstructed from the journal. */ +export class CodexPromptRegistry { + private readonly byAddress = new Map() + private readonly journalItemIds = new Map() + private readonly boundPrompts = new Map() + private readonly claims = new Map() + + get sizes(): { prompts: number; journalBindings: number } { + return { prompts: this.byAddress.size, journalBindings: this.journalItemIds.size } + } + + get bytes(): number { + return this.retainedPromptBytes() + } + + register(request: { + id: number | string + method: string + params: unknown + }): CodexPendingPrompt | null { + const codexItemId = readString(request.params, 'itemId') + const threadId = readString(request.params, 'threadId') + if (!isCodexPromptMethod(request.method) || !codexItemId || !threadId) { + return null + } + const questionIds = + request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [] + if (questionIds === null) { + return null + } + const optionAnswers = + request.method === CODEX_USER_INPUT_METHOD + ? readQuestionOptionAnswers(request.params) + : new Map() + if (optionAnswers === null) { + return null + } + const turnId = readString(request.params, 'turnId') + const turnIdentity = turnId ? codexPromptTurnIdentity(turnId) : { turnId: null } + if (turnId && turnIdentity.turnId === null) { + return null + } + const prompt: CodexPendingPrompt = { + requestId: request.id, + method: request.method, + threadId, + ...turnIdentity, + codexItemId, + promptKey: readString(request.params, 'approvalId') ?? codexItemId, + questionIds, + questionIdAliases: + request.method === CODEX_USER_INPUT_METHOD + ? new Map(questionIds.map((id) => [codexJournalPromptIdPart(id), id])) + : new Map(), + optionAnswers, + answers: new Map() + } + const promptBytes = codexPromptRegistryEntryBytes(prompt) + if (promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { + return null + } + while ( + this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES && + this.byAddress.size > 0 + ) { + const oldest = this.byAddress.values().next().value + if (!oldest) { + break + } + this.byAddress.delete(this.address(oldest.threadId, oldest.promptKey)) + } + if (this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { + return null + } + const address = this.address(prompt.threadId, prompt.promptKey) + this.byAddress.delete(address) + this.byAddress.set(address, prompt) + this.trim() + return prompt + } + + bindJournalItemId( + journalItemId: string, + threadId: string, + promptKey: string, + turnId?: string | null + ): void { + if (this.journalItemIds.has(journalItemId)) { + this.boundPrompts.delete(journalItemId) + } + this.journalItemIds.delete(journalItemId) + const address = this.address(threadId, promptKey) + const prompt = this.byAddress.get(address) + if (!prompt) { + return + } + if (prompt.turnId === null && prompt.turnIdDigest === undefined && turnId) { + Object.assign(prompt, codexPromptTurnIdentity(turnId)) + } + this.journalItemIds.set(journalItemId, address) + this.boundPrompts.set(journalItemId, prompt) + this.trim() + } + + find(journalItemId: string): CodexPendingPrompt | null { + const address = this.journalItemIds.get(journalItemId) + if (address) { + return this.boundPrompts.get(journalItemId) ?? this.byAddress.get(address) ?? null + } + const matches = [...this.byAddress.values()].filter( + (prompt) => prompt.promptKey === journalItemId + ) + return matches.length === 1 ? (matches[0] ?? null) : null + } + + claim(journalItemId: string, kind?: 'approval' | 'question'): CodexPromptClaim | null { + const prompt = this.find(journalItemId) + if (!prompt || this.claims.has(prompt) || (kind && this.kind(prompt) !== kind)) { + return null + } + const claim = { itemId: journalItemId, prompt } + this.claims.set(prompt, claim) + return claim + } + + claimBound(journalItemId: string): CodexPromptClaim | null { + const prompt = this.boundPrompts.get(journalItemId) + if (!prompt || this.claims.has(prompt)) { + return null + } + const claim = { itemId: journalItemId, prompt } + this.claims.set(prompt, claim) + return claim + } + + ownsClaim(claim: CodexPromptClaim): boolean { + return this.claims.get(claim.prompt) === claim && this.find(claim.itemId) === claim.prompt + } + + ownsBoundClaim( + claim: CodexPromptClaim, + journalItemId: string, + threadId: string, + turnId: string + ): boolean { + return ( + claim.itemId === journalItemId && + this.claims.get(claim.prompt) === claim && + this.journalItemIds.get(journalItemId) === + this.address(claim.prompt.threadId, claim.prompt.promptKey) && + this.boundPrompts.get(journalItemId) === claim.prompt && + claim.prompt.threadId === threadId && + codexPromptMatchesTurn(claim.prompt, turnId) + ) + } + + releaseClaim(claim: CodexPromptClaim): void { + if (this.claims.get(claim.prompt) === claim) { + this.claims.delete(claim.prompt) + } + } + + forget(prompt: CodexPendingPrompt): void { + this.claims.delete(prompt) + const address = this.address(prompt.threadId, prompt.promptKey) + if (this.byAddress.get(address) === prompt) { + this.byAddress.delete(address) + } + for (const [journalItemId, boundPrompt] of this.boundPrompts) { + if (boundPrompt === prompt) { + this.journalItemIds.delete(journalItemId) + this.boundPrompts.delete(journalItemId) + } + } + } + + clearTurn(threadId: string, turnId: string): void { + const prompts = new Set( + [...this.byAddress.values(), ...this.boundPrompts.values()].filter( + (prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId) + ) + ) + for (const prompt of prompts) { + this.forget(prompt) + } + } + + clear(): void { + this.byAddress.clear() + this.journalItemIds.clear() + this.boundPrompts.clear() + this.claims.clear() + } + + private address(threadId: string, promptKey: string): string { + return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}` + } + + private kind(prompt: CodexPendingPrompt): 'approval' | 'question' { + return prompt.method === CODEX_USER_INPUT_METHOD ? 'question' : 'approval' + } + + private retainedPromptBytes(): number { + const prompts = new Set([...this.byAddress.values(), ...this.boundPrompts.values()]) + return [...prompts].reduce((total, prompt) => total + codexPromptRegistryEntryBytes(prompt), 0) + } + + private trim(): void { + while (this.byAddress.size > MAX_CODEX_PROMPT_REGISTRY_ENTRIES) { + const oldest = this.byAddress.values().next().value + if (!oldest) { + break + } + this.byAddress.delete(this.address(oldest.threadId, oldest.promptKey)) + } + while (this.journalItemIds.size > MAX_CODEX_PROMPT_JOURNAL_BINDINGS) { + const oldest = this.journalItemIds.keys().next().value + if (!oldest) { + break + } + this.journalItemIds.delete(oldest) + this.boundPrompts.delete(oldest) + } + } +} diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index 4114f9b0355..10fe26f8f72 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -28,6 +28,7 @@ export type CodexJournalTranslatorDeps = { export type CodexJournalTranslator = { handle: (event: CodexStructuredSessionEvent) => CodexJournalTranslationAdmission + cancelPrompt: (journalItemId: string) => CodexJournalTranslationAdmission restoreThread: ( threadId: string, thread: Record diff --git a/src/main/codex/codex-structured-journal-prompts.ts b/src/main/codex/codex-structured-journal-prompts.ts index 3a3f57576cb..f72fd6264ef 100644 --- a/src/main/codex/codex-structured-journal-prompts.ts +++ b/src/main/codex/codex-structured-journal-prompts.ts @@ -15,13 +15,16 @@ import { MAX_CODEX_PENDING_PROMPTS } from './codex-structured-journal-limits' import { admitCodexLifecycleItems, appendCodexLifecycleItem, + appendCodexLifecycleMutations, publishCodexLifecycle } from './codex-structured-journal-sink' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' import { readCodexTurnId } from './codex-structured-thread-facts' +type CodexGroupedPendingJournalPrompt = CodexPendingJournalPrompt & { promptKey: string } + export class CodexJournalPrompts { - readonly pending = new Map() + readonly pending = new Map() constructor( private readonly deps: Pick, @@ -53,6 +56,7 @@ export class CodexJournalPrompts { this.pending.set(itemId, { threadId: event.threadId, turnId, + promptKey: event.promptKey, identity: question.identity, body: question.body }) @@ -81,6 +85,7 @@ export class CodexJournalPrompts { this.pending.set(itemId, { threadId: event.threadId, turnId, + promptKey: event.promptKey, identity, body }) @@ -96,6 +101,36 @@ export class CodexJournalPrompts { this.pending.delete(journalItemId) } + cancel(journalItemId: string): CodexJournalTranslationAdmission { + const selected = this.pending.get(journalItemId) + if (!selected) { + return CODEX_JOURNAL_ADMITTED + } + const group = [...this.pending].filter( + ([, prompt]) => + prompt.threadId === selected.threadId && + prompt.turnId === selected.turnId && + prompt.promptKey === selected.promptKey + ) + const mutations = group.flatMap(([, prompt]) => { + const body = cancelledJournalPromptBody(prompt.body) + return body ? [{ kind: 'item' as const, identity: prompt.identity, body }] : [] + }) + const admission = appendCodexLifecycleMutations( + this.deps.sink, + `prompt-cancelled:${encodeURIComponent(selected.threadId)}:${encodeURIComponent( + selected.promptKey + )}:${encodeURIComponent(selected.turnId ?? 'unbound')}`, + mutations + ) + if (admission.accepted) { + for (const [itemId] of group) { + this.pending.delete(itemId) + } + } + return admission + } + dispose(): void { this.pending.clear() } diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index a19c2e66f82..43e9a6642de 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -273,6 +273,7 @@ export function createCodexJournalTranslator( genericFrames.appendUnhandled(`notification:${event.method}`, event.params, event.threadId) ) }, + cancelPrompt: (journalItemId) => prompts.cancel(journalItemId), resolvePrompt: (journalItemId) => prompts.resolve(journalItemId), flush: () => { items.streams.flush() diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts new file mode 100644 index 00000000000..cfb77e628a7 --- /dev/null +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -0,0 +1,680 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { + THREAD_ID, + acquired, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' +import type { CodexStructuredSessionEvent } from './codex-structured-session-state' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((finish) => { + resolve = finish + }) + return { promise, resolve } +} + +function registerPrompt( + adapter: Awaited>, + codex: ReturnType, + itemId = 'journal-prompt', + threadId = THREAD_ID, + turnId = 'turn-1' +): void { + codex.connections[0]?.handlers.onServerRequest?.({ + id: 11, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', threadId, turnId } + }) + adapter.bindPromptItemId('session-1', itemId, 'codex-item-1', turnId, threadId) +} + +function registerGroupedQuestionPrompt( + codex: ReturnType, + threadId = THREAD_ID, + turnId = 'turn-1' +): void { + codex.connections[0]?.handlers.onServerRequest?.({ + id: 12, + method: 'item/tool/requestUserInput', + params: { + itemId: 'codex-question-group', + threadId, + turnId, + questions: [ + { id: 'first', question: 'First?', options: [{ label: 'yes' }] }, + { id: 'second', question: 'Second?', options: [{ label: 'no' }] } + ] + } + }) +} + +function completeTurn( + codex: ReturnType, + threadId: string, + turnId = 'turn-1' +): void { + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId, + turn: { id: turnId, status: 'interrupted' } + }) +} + +function completionThreads(events: CodexStructuredSessionEvent[]): string[] { + return events.flatMap((event) => + event.type === 'notification' && event.method === 'turn/completed' ? [event.threadId] : [] + ) +} + +function lifecycleRecorder( + acceptPromptCancellation = true, + acceptTurnCompletion = true +): { + sink: StructuredAgentSessionEventSink + bodies: Map + order: string[] +} { + const bodies = new Map() + const order: string[] = [] + const settlements = new Set() + const append = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => { + bodies.set(agentJournalItemKey(identity), body) + } + const sink: StructuredAgentSessionEventSink = { + appendItem: append, + appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), + publish: () => {}, + tryAppendItem: (identity, body, options) => { + if ( + body.kind === 'approval' && + body.resolution.state === 'cancelled' && + options?.lifecycle === true + ) { + if (!acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + order.push('prompt-lifecycle') + } + append(identity, body) + return { accepted: true } + }, + tryAppendLifecycleBatch: (settlementId, mutations) => { + const cancelsPrompt = mutations.some( + (mutation) => + mutation.kind === 'item' && + (mutation.body.kind === 'approval' || mutation.body.kind === 'question') && + mutation.body.resolution.state === 'cancelled' + ) + if (cancelsPrompt && !acceptPromptCancellation) { + return { accepted: false, reason: 'backpressure' } + } + if (settlementId.startsWith('turn-completed:') && !acceptTurnCompletion) { + return { accepted: false, reason: 'backpressure' } + } + if (settlements.has(settlementId)) { + return { accepted: true } + } + settlements.add(settlementId) + for (const mutation of mutations) { + if (mutation.kind === 'item') { + append(mutation.identity, mutation.body) + } else { + bodies.delete(agentJournalItemKey(mutation.identity)) + } + } + if (cancelsPrompt) { + order.push('prompt-lifecycle') + } + if (settlementId.startsWith('turn-completed:')) { + order.push('turn-lifecycle') + } + return { accepted: true } + }, + tryPublish: () => ({ accepted: true }) + } + return { sink, bodies, order } +} + +describe('Codex live prompt ownership', () => { + it('lets an answer hold the callback claim through its journal commit', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + const commitGate = deferred() + const commitStarted = vi.fn() + + const answer = adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 7, + commit: async () => { + expect(codex.connections[0]?.replies).toEqual([]) + commitStarted() + await commitGate.promise + } + }) + await vi.waitFor(() => expect(commitStarted).toHaveBeenCalledOnce()) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(codex.connections[0]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) + + commitGate.resolve() + await answer + expect(codex.connections[0]?.replies).toEqual([{ id: 11, result: { decision: 'accept' } }]) + }) + + it('lets prompt cancellation win and retains its claim until terminal cleanup', async () => { + const interruptGate = deferred() + const codex = fakeCodex({ + 'turn/interrupt': async () => { + await interruptGate.promise + completeTurn(codex, THREAD_ID) + } + }) + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + + const cancellation = adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + await vi.waitFor(() => + expect(codex.connections[0]?.calls.at(-1)?.method).toBe('turn/interrupt') + ) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + + interruptGate.resolve() + await expect(cancellation).resolves.toEqual({ cancelled: true }) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + + await adapter.closeSession('session-1') + await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'accept', + fence: 8, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('releases the callback claim after a failed interrupt', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => { + throw new CodexAppServerRequestError('turn/interrupt', -32602, 'no such turn') + } + }) + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-prompt', + kind: 'approval', + optionId: 'decline', + fence: 7, + commit: async () => undefined + }) + expect(codex.connections[0]?.replies).toEqual([{ id: 11, result: { decision: 'decline' } }]) + }) + + it('interrupts only the child provider turn when its controller turn differs', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => completeTurn(codex, 'thread-child', 'child-turn') + }) + const terminateTurnProcesses = vi.fn(async () => true) + const adapter = adapterFor(codex, {}, [], { terminateTurnProcesses }) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) + registerPrompt(adapter, codex, 'child-prompt', 'thread-child', 'child-turn') + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'root-turn', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + ).resolves.toEqual({ cancelled: true }) + expect(codex.connections[0]?.calls.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: 'thread-child', turnId: 'child-turn' } + }) + expect(terminateTurnProcesses).not.toHaveBeenCalled() + expect(codex.connections[0]?.closed).toBe(false) + }) + + it('keeps a wire-valid multibyte prompt turn id as the exact interrupt target', async () => { + const promptTurnId = '界'.repeat(171) + expect(promptTurnId.length).toBeLessThanOrEqual(AGENT_SESSION_ID_MAX_LENGTH) + expect(Buffer.byteLength(promptTurnId, 'utf8')).toBeGreaterThan(AGENT_SESSION_ID_MAX_LENGTH) + const codex = fakeCodex({ + 'turn/interrupt': () => completeTurn(codex, 'thread-child', promptTurnId) + }) + const adapter = await acquired(codex) + registerPrompt(adapter, codex, 'child-prompt', 'thread-child', promptTurnId) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'root-turn', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + ).resolves.toEqual({ cancelled: true }) + expect(codex.connections[0]?.calls.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: 'thread-child', turnId: promptTurnId } + }) + }) + + it('settles a grouped prompt and its running turn before reporting cancellation', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => { + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'interrupted', durationMs: 456 } + }) + } + }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + codex.connections[0]?.handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: 'turn-1' } + }) + registerGroupedQuestionPrompt(codex) + const questionItemIds = [...recorded.bodies] + .filter(([, body]) => body.kind === 'question') + .map(([itemId]) => itemId) + expect(questionItemIds).toHaveLength(2) + const selectedItemId = questionItemIds[0] + const siblingItemId = questionItemIds[1] + if (!selectedItemId || !siblingItemId) { + throw new Error('expected two durable Codex questions') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: selectedItemId } + }) + ).resolves.toEqual({ cancelled: true }) + expect( + questionItemIds.map((itemId) => { + const body = recorded.bodies.get(itemId) + return body?.kind === 'question' ? body.resolution.state : null + }) + ).toEqual(['cancelled', 'cancelled']) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 456 + }) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: siblingItemId, + kind: 'question', + optionId: 'no', + fence: 7, + commit: async () => undefined + }) + ).rejects.toThrow(/no longer waiting/) + }) + + it('enqueues terminal prompt state before a confirmed cancellation resolves', async () => { + const codex = fakeCodex({ + 'turn/interrupt': () => { + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'interrupted', durationMs: 321 } + }) + } + }) + const recorded = lifecycleRecorder() + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + .then((result) => { + recorded.order.push('resolved') + return result + }) + + await expect(cancellation).resolves.toEqual({ cancelled: true }) + expect(recorded.order).toEqual(['prompt-lifecycle', 'turn-lifecycle', 'resolved']) + expect( + [...recorded.bodies.values()].some( + (body) => body.kind === 'approval' && body.resolution.state === 'cancelled' + ) + ).toBe(true) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 321 + }) + + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'completed', durationMs: 999 } + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 321 + }) + }) + + it('settles the prompt without inventing turn completion when none was observed', async () => { + const codex = fakeCodex() + const recorded = lifecycleRecorder() + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + codex.connections[0]?.handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: 'turn-1' } + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).resolves.toEqual({ cancelled: true }) + expect(recorded.bodies.get(promptItemId)).toMatchObject({ + kind: 'approval', + resolution: { state: 'cancelled' } + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'running' + }) + + codex.connections[0]?.handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: 'turn-1', status: 'interrupted', durationMs: 777 } + }) + expect([...recorded.bodies.values()].find((body) => readAgentJournalTurn(body))).toMatchObject({ + state: 'interrupted', + durationMs: 777 + }) + + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('does not synthesize terminal lifecycle for ordinary Stop', async () => { + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(fakeCodex(), {}, events) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(completionThreads(events)).toEqual([]) + }) + + it('does not report success or release the claim when prompt lifecycle admission fails', async () => { + const codex = fakeCodex() + const recorded = lifecycleRecorder(false) + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).rejects.toThrow(/lifecycle was not admitted/) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + }) + + it('does not report success when a deferred provider completion is backpressured', async () => { + const recorded = lifecycleRecorder(true, false) + const codex = fakeCodex({ + 'turn/interrupt': () => { + completeTurn(codex, THREAD_ID) + } + }) + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: recorded.sink + }) + registerPrompt(adapter, codex) + const promptItemId = [...recorded.bodies].find(([, body]) => body.kind === 'approval')?.[0] + if (!promptItemId) { + throw new Error('expected durable Codex prompt') + } + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: promptItemId } + }) + ).rejects.toThrow(/deferred turn completion lifecycle was not admitted/) + const commit = vi.fn(async () => undefined) + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: promptItemId, + kind: 'approval', + optionId: 'accept', + fence: 7, + commit + }) + ).rejects.toThrow(/no longer waiting/) + expect(commit).not.toHaveBeenCalled() + + await adapter.closeSession('session-1') + }) + + it('defers only the matching thread and emits its terminal event before cancel resolves', async () => { + const interruptGate = deferred() + const events: CodexStructuredSessionEvent[] = [] + const codex = fakeCodex({ + 'turn/interrupt': () => { + completeTurn(codex, THREAD_ID) + completeTurn(codex, 'thread-child') + return interruptGate.promise + } + }) + const adapter = await acquired(codex, {}, events) + registerPrompt(adapter, codex, 'child-prompt', 'thread-child') + + const cancellation = adapter + .cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + .then((result) => { + expect(completionThreads(events)).toEqual([THREAD_ID, 'thread-child']) + return result + }) + await vi.waitFor(() => expect(completionThreads(events)).toEqual([THREAD_ID])) + + interruptGate.resolve() + await expect(cancellation).resolves.toEqual({ cancelled: true }) + }) + + it('checks the bound item, fence, and current acquisition before interrupting', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + registerPrompt(adapter, codex) + + for (const input of [ + { turnId: 'turn-1', fence: 7, itemId: 'other-item' }, + { turnId: 'turn-1', fence: 6, itemId: 'journal-prompt' } + ]) { + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: input.turnId, + fence: input.fence, + prompt: { itemId: input.itemId } + }) + ).resolves.toEqual({ cancelled: false }) + } + expect(codex.connections[0]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) + + await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 8, + prompt: { itemId: 'journal-prompt' } + }) + ).resolves.toEqual({ cancelled: false }) + expect(codex.connections[1]?.calls.some((call) => call.method === 'turn/interrupt')).toBe(false) + }) + + it('drops a retained cancellation claim with normal turn cleanup', () => { + const prompts = new CodexPromptRegistry() + prompts.register({ + id: 11, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', threadId: THREAD_ID, turnId: 'turn-1' } + }) + prompts.bindJournalItemId('journal-prompt', THREAD_ID, 'codex-item-1', 'turn-1') + const claim = prompts.claimBound('journal-prompt') + if (!claim) { + throw new Error('expected prompt claim') + } + + prompts.clearTurn(THREAD_ID, 'turn-1') + + expect(prompts.ownsClaim(claim)).toBe(false) + expect(prompts.find('journal-prompt')).toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts new file mode 100644 index 00000000000..28d060d955f --- /dev/null +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -0,0 +1,103 @@ +import { + AgentSessionPromptUnavailableError, + type StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' +import { answerCodexPrompt } from './codex-structured-prompt-replies' +import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' +import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' + +type CancelInput = Parameters[0] +type AnswerInput = Parameters[0] + +export async function cancelCodexStructuredTurn(input: { + request: CancelInput + sessions: Map + compactions: StructuredSessionCompaction + cancellation: CodexStructuredTurnCancellation +}): Promise<{ cancelled: boolean }> { + const { request, sessions, compactions, cancellation } = input + const session = requireLiveCodexSession(sessions, request.sessionId) + const turnId = compactions.providerTurnId(request.sessionId, request.turnId) + if (!turnId) { + return { cancelled: false } + } + const prompt = request.prompt + if (!prompt) { + return cancellation.cancel(session, session.threadId, turnId) + } + if (session.fence !== request.fence) { + return { cancelled: false } + } + const acquisitionGeneration = session.acquisitionGeneration + const claim = session.prompts.claimBound(prompt.itemId) + const promptTurnId = claim?.prompt.turnId + if (!claim || !promptTurnId) { + if (claim) { + session.prompts.releaseClaim(claim) + } + return { cancelled: false } + } + const isCurrent = (): boolean => + sessions.get(request.sessionId) === session && + !session.ended && + session.fence === request.fence && + session.acquisitionGeneration === acquisitionGeneration && + compactions.providerTurnId(request.sessionId, request.turnId) === turnId && + session.prompts.ownsBoundClaim(claim, prompt.itemId, claim.prompt.threadId, promptTurnId) + let interruptConfirmed = false + try { + const result = await cancellation.cancel( + session, + claim.prompt.threadId, + promptTurnId, + isCurrent, + () => { + interruptConfirmed = true + return session.translator?.cancelPrompt(prompt.itemId) ?? { accepted: true } + } + ) + if (!result.cancelled) { + session.prompts.releaseClaim(claim) + } + return result + } catch (error) { + if (!interruptConfirmed) { + session.prompts.releaseClaim(claim) + } + throw error + } +} + +export async function answerCodexStructuredPrompt(input: { + request: AnswerInput + sessions: Map +}): Promise { + const { request, sessions } = input + const session = sessions.get(request.sessionId) + if (!session || session.ended || session.fence !== request.fence) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + const acquisitionGeneration = session.acquisitionGeneration + const claim = session.prompts.claim(request.itemId, request.kind) + if (!claim) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + try { + await request.commit() + if ( + sessions.get(request.sessionId) !== session || + session.ended || + session.fence !== request.fence || + session.acquisitionGeneration !== acquisitionGeneration || + !session.prompts.ownsClaim(claim) + ) { + throw new AgentSessionPromptUnavailableError(request.itemId) + } + session.translator?.resolvePrompt(request.itemId) + answerCodexPrompt(session.prompts, session.connection, claim, request.optionId) + } catch (error) { + session.prompts.releaseClaim(claim) + throw error + } +} diff --git a/src/main/codex/codex-structured-prompt-replies.test.ts b/src/main/codex/codex-structured-prompt-replies.test.ts index 49626ebd0a1..e575f27632b 100644 --- a/src/main/codex/codex-structured-prompt-replies.test.ts +++ b/src/main/codex/codex-structured-prompt-replies.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' import { applyCodexPromptAnswer, CodexPromptRegistry, @@ -122,7 +123,7 @@ describe('CodexPromptRegistry', () => { expect(registry.find('other-thread-item')?.requestId).toBe(3) }) - it('bounds an oversized backfilled turn id and still clears its prompt', () => { + it('retains a bounded cleanup identity for an unaddressable backfilled turn id', () => { const registry = new CodexPromptRegistry() const turnId = 'turn-'.padEnd(MAX_CODEX_PROMPT_REGISTRY_BYTES + 1, 'x') registry.register({ @@ -138,6 +139,36 @@ describe('CodexPromptRegistry', () => { expect(registry.find('journal-root')).toBeNull() }) + it('reserves enough bytes for a wire-valid multibyte backfilled turn id', () => { + const registry = new CodexPromptRegistry() + registry.register({ + id: 1, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'root-item', threadId: 'thread-1' } + }) + const reservedBytes = registry.bytes + const turnId = '界'.repeat(AGENT_SESSION_ID_MAX_LENGTH) + + registry.bindJournalItemId('journal-root', 'thread-1', 'root-item', turnId) + + expect(registry.find('journal-root')?.turnId).toBe(turnId) + expect(registry.bytes).toBe(reservedBytes) + expect(registry.bytes).toBeLessThanOrEqual(MAX_CODEX_PROMPT_REGISTRY_BYTES) + }) + + it('rejects a request turn id beyond the wire identity bound', () => { + const registry = new CodexPromptRegistry() + const turnId = 'x'.repeat(AGENT_SESSION_ID_MAX_LENGTH + 1) + const prompt = registry.register({ + id: 1, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'root-item', threadId: 'thread-1', turnId } + }) + + expect(prompt).toBeNull() + expect(registry.bytes).toBe(0) + }) + it('addresses a prompt by its journal item id once bound, and forgets both', () => { const registry = new CodexPromptRegistry() const prompt = registry.register(userInputRequest(['q1'])) diff --git a/src/main/codex/codex-structured-prompt-replies.ts b/src/main/codex/codex-structured-prompt-replies.ts index 9f30bfe1a8d..6e742bf827c 100644 --- a/src/main/codex/codex-structured-prompt-replies.ts +++ b/src/main/codex/codex-structured-prompt-replies.ts @@ -1,16 +1,11 @@ import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CODEX_PROMPT_MAX_ANSWER_BYTES } from './codex-prompt-registry-bounds' import { - CODEX_PROMPT_MAX_ANSWER_BYTES, - MAX_CODEX_PROMPT_JOURNAL_BINDINGS, - MAX_CODEX_PROMPT_REGISTRY_BYTES, - MAX_CODEX_PROMPT_REGISTRY_ENTRIES, - codexPromptMatchesTurn, - codexPromptRegistryEntryBytes, - codexPromptTurnIdentity, - codexJournalPromptIdPart, - readQuestionIds, - readQuestionOptionAnswers -} from './codex-prompt-registry-bounds' + CODEX_USER_INPUT_METHOD, + type CodexPendingPrompt, + type CodexPromptClaim, + type CodexPromptRegistry +} from './codex-prompt-registry' export { codexJournalPromptIdPart, MAX_CODEX_PROMPT_REGISTRY_ENTRIES, @@ -18,39 +13,23 @@ export { MAX_CODEX_PROMPT_REGISTRY_BYTES, encodeCodexJournalQuestionOptionId } from './codex-prompt-registry-bounds' - -// Codex asks for approvals and tool input by sending JSON-RPC REQUESTS back to -// Orca, and the turn blocks until each one is answered. The journal answers them -// much later, through a durable item id, so this module holds the live request -// ids and turns a chosen option back into the reply payload Codex expects. - -export const CODEX_COMMAND_APPROVAL_METHOD = 'item/commandExecution/requestApproval' -export const CODEX_FILE_CHANGE_APPROVAL_METHOD = 'item/fileChange/requestApproval' -export const CODEX_USER_INPUT_METHOD = 'item/tool/requestUserInput' +export { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CodexPromptRegistry, + isCodexPromptMethod, + type CodexPendingPrompt, + type CodexPromptClaim +} from './codex-prompt-registry' /** The decisions Codex accepts for both approval requests. Anything else is a * client-supplied option id that never came from a Codex prompt. */ export const CODEX_APPROVAL_DECISIONS = ['accept', 'acceptForSession', 'decline', 'cancel'] as const export type CodexApprovalDecision = (typeof CODEX_APPROVAL_DECISIONS)[number] -export type CodexPendingPrompt = { - requestId: number | string - method: string - threadId: string - turnId: string | null - /** Oversized compatibility turn ids stay comparable without escaping the registry byte cap. */ - turnIdDigest?: string - codexItemId: string - /** What addresses this prompt. One tool item can ask more than once — a shell - * bridge re-asks per command under the same `itemId` — so the request's own - * `approvalId` is the identity whenever Codex sends one. */ - promptKey: string - /** One entry per question for a user-input request; empty for an approval. */ - questionIds: readonly string[] - /** Journal-facing ids can be bounded; replies still need Codex's exact ids. */ - questionIdAliases: ReadonlyMap - optionAnswers: ReadonlyMap - answers: Map +function isCodexApprovalDecision(optionId: string): optionId is CodexApprovalDecision { + return CODEX_APPROVAL_DECISIONS.some((decision) => decision === optionId) } /** A user-input request can carry several questions but takes ONE reply, so an @@ -76,208 +55,6 @@ export function decodeCodexQuestionOptionId( } } -function readString(params: unknown, key: string): string | null { - if (typeof params !== 'object' || params === null) { - return null - } - const value = (params as Record)[key] - return typeof value === 'string' && value.length > 0 ? value : null -} - -export function isCodexPromptMethod(method: string): boolean { - return ( - method === CODEX_COMMAND_APPROVAL_METHOD || - method === CODEX_FILE_CHANGE_APPROVAL_METHOD || - method === CODEX_USER_INPUT_METHOD - ) -} - -/** - * Live Codex prompt requests for one session, addressable by the journal item - * id the client will eventually answer with. The binding is registered by the - * translation module, because only it knows which journal item a Codex item - * became. - */ -export class CodexPromptRegistry { - private readonly byAddress = new Map() - /** Journal item id to thread-scoped prompt address. */ - private readonly journalItemIds = new Map() - /** Bound prompts survive LRU eviction of the lookup window until answered. */ - private readonly boundPrompts = new Map() - - get sizes(): { prompts: number; journalBindings: number } { - return { prompts: this.byAddress.size, journalBindings: this.journalItemIds.size } - } - - get bytes(): number { - return this.retainedPromptBytes() - } - - private promptBytes(prompt: CodexPendingPrompt): number { - return codexPromptRegistryEntryBytes(prompt) - } - - private retainedPromptBytes(): number { - const prompts = new Set([...this.byAddress.values(), ...this.boundPrompts.values()]) - return [...prompts].reduce((total, prompt) => total + this.promptBytes(prompt), 0) - } - - private trim(): void { - while (this.byAddress.size > MAX_CODEX_PROMPT_REGISTRY_ENTRIES) { - const oldest = this.byAddress.values().next().value as CodexPendingPrompt | undefined - if (!oldest) { - break - } - const address = this.address(oldest.threadId, oldest.promptKey) - this.byAddress.delete(address) - } - while (this.journalItemIds.size > MAX_CODEX_PROMPT_JOURNAL_BINDINGS) { - const oldest = this.journalItemIds.keys().next().value as string | undefined - if (!oldest) { - break - } - this.journalItemIds.delete(oldest) - this.boundPrompts.delete(oldest) - } - } - - private address(threadId: string, promptKey: string): string { - return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}` - } - - /** Returns null for a request this build does not model, so the caller can - * refuse it instead of leaving Codex blocked on an answer forever. */ - register(request: { - id: number | string - method: string - params: unknown - }): CodexPendingPrompt | null { - const codexItemId = readString(request.params, 'itemId') - const threadId = readString(request.params, 'threadId') - if (!isCodexPromptMethod(request.method) || !codexItemId || !threadId) { - return null - } - const questionIds = - request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [] - if (questionIds === null) { - return null - } - const optionAnswers = - request.method === CODEX_USER_INPUT_METHOD - ? readQuestionOptionAnswers(request.params) - : new Map() - if (optionAnswers === null) { - return null - } - const prompt: CodexPendingPrompt = { - requestId: request.id, - method: request.method, - threadId, - turnId: readString(request.params, 'turnId'), - codexItemId, - promptKey: readString(request.params, 'approvalId') ?? codexItemId, - questionIds, - questionIdAliases: - request.method === CODEX_USER_INPUT_METHOD - ? new Map(questionIds.map((id) => [codexJournalPromptIdPart(id), id])) - : new Map(), - optionAnswers, - answers: new Map() - } - const promptBytes = this.promptBytes(prompt) - if (promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { - return null - } - while ( - this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES && - this.byAddress.size > 0 - ) { - const oldest = this.byAddress.values().next().value as CodexPendingPrompt | undefined - if (!oldest) { - break - } - this.byAddress.delete(this.address(oldest.threadId, oldest.promptKey)) - } - if (this.retainedPromptBytes() + promptBytes > MAX_CODEX_PROMPT_REGISTRY_BYTES) { - return null - } - const address = this.address(prompt.threadId, prompt.promptKey) - this.byAddress.delete(address) - this.byAddress.set(address, prompt) - this.trim() - return prompt - } - - /** Called by the translation module once the prompt has a journal id. */ - bindJournalItemId( - journalItemId: string, - threadId: string, - promptKey: string, - turnId?: string | null - ): void { - const existing = this.journalItemIds.get(journalItemId) - if (existing) { - this.boundPrompts.delete(journalItemId) - } - this.journalItemIds.delete(journalItemId) - const address = this.address(threadId, promptKey) - const prompt = this.byAddress.get(address) - if (!prompt) { - return - } - if (prompt.turnId === null && prompt.turnIdDigest === undefined && turnId) { - Object.assign(prompt, codexPromptTurnIdentity(turnId)) - } - this.journalItemIds.set(journalItemId, address) - this.boundPrompts.set(journalItemId, prompt) - this.trim() - } - - /** Falls back to treating the id as a prompt key, which is what it is before - * any binding exists. */ - find(journalItemId: string): CodexPendingPrompt | null { - const address = this.journalItemIds.get(journalItemId) - if (address) { - return this.boundPrompts.get(journalItemId) ?? this.byAddress.get(address) ?? null - } - const matches = [...this.byAddress.values()].filter( - (prompt) => prompt.promptKey === journalItemId - ) - return matches.length === 1 ? matches[0]! : null - } - - forget(prompt: CodexPendingPrompt): void { - const address = this.address(prompt.threadId, prompt.promptKey) - if (this.byAddress.get(address) === prompt) { - this.byAddress.delete(address) - } - for (const [journalItemId, boundPrompt] of this.boundPrompts) { - if (boundPrompt === prompt) { - this.journalItemIds.delete(journalItemId) - this.boundPrompts.delete(journalItemId) - } - } - } - - /** Drops requests that belonged to a turn which the provider has settled. */ - clearTurn(threadId: string, turnId: string): void { - const prompts = new Set( - [...this.byAddress.values(), ...this.boundPrompts.values()].filter( - (prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId) - ) - ) - for (const prompt of prompts) { - this.forget(prompt) - } - } - - clear(): void { - this.byAddress.clear() - this.journalItemIds.clear() - this.boundPrompts.clear() - } -} - /** * Records one answer and returns the reply payload once the request is fully * answered. A multi-question user-input request stays pending until every @@ -288,7 +65,7 @@ export function applyCodexPromptAnswer( optionId: string ): Record | null { if (prompt.method !== CODEX_USER_INPUT_METHOD) { - if (!(CODEX_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { + if (!isCodexApprovalDecision(optionId)) { throw new Error(`${optionId} is not a Codex approval decision`) } return { decision: optionId } @@ -312,7 +89,11 @@ export function applyCodexPromptAnswer( } const answers: Record = {} for (const id of prompt.questionIds) { - answers[id] = { answers: [prompt.answers.get(id) as string] } + const answer = prompt.answers.get(id) + if (answer === undefined) { + return null + } + answers[id] = { answers: [answer] } } return { answers } } @@ -322,15 +103,16 @@ export function applyCodexPromptAnswer( export function answerCodexPrompt( registry: CodexPromptRegistry, connection: Pick, - itemId: string, + claim: CodexPromptClaim, optionId: string ): void { - const prompt = registry.find(itemId) - if (!prompt) { - throw new Error(`codex app-server is no longer waiting on ${itemId}`) + if (!registry.ownsClaim(claim)) { + throw new Error(`codex app-server is no longer waiting on ${claim.itemId}`) } + const prompt = claim.prompt const reply = applyCodexPromptAnswer(prompt, optionId) if (reply === null) { + registry.releaseClaim(claim) return } // Forget first: a second answer must find nothing rather than reply twice. diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index b2c579770fd..fc458304dc8 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -153,7 +153,8 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { itemId: 'codex-item-1', kind: 'approval', optionId: 'accept', - fence: 1 + fence: 1, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on') diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index d32c3013b6c..e04e18ccd08 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -139,7 +139,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => { itemId: 'codex-item-early', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) expect(codex.connections[0].replies).toEqual([{ id: 5, result: { decision: 'accept' } }]) }) @@ -496,7 +497,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) expect(events.at(-1)).toMatchObject({ type: 'prompt', codexItemId: 'codex-item-1' }) @@ -508,7 +510,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', optionId: 'decline', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on') expect(codex.connections[0].replies).toHaveLength(1) @@ -548,7 +551,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-1', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on') }) @@ -634,7 +638,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId, kind: 'approval', optionId, - fence: 7 + fence: 7, + commit: async () => undefined }) } @@ -660,7 +665,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-1', kind: 'approval', optionId: 'yolo', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('is not a Codex approval decision') expect(codex.connections[0].replies).toEqual([]) @@ -688,7 +694,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-2', kind: 'question', optionId: encodeCodexQuestionOptionId('q1', 'yes'), - fence: 7 + fence: 7, + commit: async () => undefined }) expect(codex.connections[0].replies).toEqual([]) @@ -697,7 +704,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-2', kind: 'question', optionId: encodeCodexQuestionOptionId('q2', 'no'), - fence: 7 + fence: 7, + commit: async () => undefined }) expect(codex.connections[0].replies).toEqual([ @@ -732,7 +740,8 @@ describe('CodexStructuredSessionAdapter prompts', () => { itemId: 'codex-item-gone', kind: 'approval', optionId: 'accept', - fence: 7 + fence: 7, + commit: async () => undefined }) ).rejects.toThrow('no longer waiting on codex-item-gone') }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index d47bd81fc8e..d7d8b2f6ad1 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -13,7 +13,6 @@ import type { StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' -import { answerCodexPrompt } from './codex-structured-prompt-replies' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' import { supportsCodexStructuredLocation } from './codex-structured-location-support' import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' @@ -37,6 +36,10 @@ import { import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' +import { + answerCodexStructuredPrompt, + cancelCodexStructuredTurn +} from './codex-structured-prompt-ownership' export type { CodexStructuredLaunch, @@ -183,13 +186,14 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessionId: string, journalItemId: string, promptKey: string, - turnId?: string | null + turnId?: string | null, + threadId?: string ): void => this.sessions .get(sessionId) ?.prompts.bindJournalItemId( journalItemId, - this.session(sessionId).threadId, + threadId ?? this.session(sessionId).threadId, promptKey, turnId ) @@ -210,15 +214,13 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } } - async cancelTurn(input: { - sessionId: string - turnId: string - fence: number - }): Promise<{ cancelled: boolean }> { - const session = this.session(input.sessionId) - const turnId = this.compactions.providerTurnId(input.sessionId, input.turnId) - return turnId ? this.turnCancellation.cancel(session, turnId) : { cancelled: false } - } + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) => + cancelCodexStructuredTurn({ + request, + sessions: this.sessions, + compactions: this.compactions, + cancellation: this.turnCancellation + }) rewindSupport: NonNullable = (sessionId) => this.sessions.get(sessionId)?.historyMode === 'legacy' @@ -256,17 +258,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ) } - async answerPrompt(input: { - sessionId: string - itemId: string - kind: 'approval' | 'question' - optionId: string - fence: number - }): Promise { - const session = this.session(input.sessionId) - answerCodexPrompt(session.prompts, session.connection, input.itemId, input.optionId) - session.translator?.resolvePrompt(input.itemId) - } + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => + answerCodexStructuredPrompt({ request, sessions: this.sessions }) async setOption( input: StructuredAgentSessionSetOptionInput diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 97257d54fa4..4418da81057 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -8,6 +8,7 @@ import type { CodexStructuredSessionAdapterDeps, CodexStructuredSessionEvent } from './codex-structured-session-state' +import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' import { captureCodexTurnProcesses, @@ -21,13 +22,22 @@ type TurnProcessState = { deferredCompletions: Map } +function turnKey(threadId: string, turnId: string): string { + return JSON.stringify([threadId, turnId]) +} + type TurnCancellationDeps = Pick< CodexStructuredSessionAdapterDeps, 'captureTurnProcesses' | 'requestTimeoutMs' | 'terminateTurnProcesses' > & { - emit: (session: CodexSession, event: CodexStructuredSessionEvent) => void + emit: ( + session: CodexSession, + event: CodexStructuredSessionEvent + ) => CodexJournalTranslationAdmission } +const ADMITTED: CodexJournalTranslationAdmission = { accepted: true } + export class CodexStructuredTurnCancellation { private readonly states = new WeakMap() @@ -54,12 +64,13 @@ export class CodexStructuredTurnCancellation { observedAt?: number ): boolean { const threadId = readCodexThreadId(params) ?? session.threadId - if (method !== 'turn/completed' || threadId !== session.threadId) { + if (method !== 'turn/completed') { return false } const turnId = readCodexTurnId(params) const state = this.state(session) - if (!turnId || !state.blockedCompletions.has(turnId)) { + const key = turnId ? turnKey(threadId, turnId) : null + if (!key || !state.blockedCompletions.has(key)) { return false } const event = { @@ -70,21 +81,29 @@ export class CodexStructuredTurnCancellation { params, ...(observedAt !== undefined ? { observedAt } : {}) } - state.deferredCompletions.set(turnId, event) + state.deferredCompletions.set(key, event) return true } - async cancel(session: CodexSession, turnId: string): Promise<{ cancelled: boolean }> { + async cancel( + session: CodexSession, + threadId: string, + turnId: string, + isCurrent: () => boolean = () => true, + onConfirmed?: () => CodexJournalTranslationAdmission + ): Promise<{ cancelled: boolean }> { const state = this.state(session) - state.blockedCompletions.add(turnId) - const baseline = await state.baseline + const key = turnKey(threadId, turnId) + state.blockedCompletions.add(key) + const targetsPrimaryTurn = threadId === session.threadId + const baseline = targetsPrimaryTurn ? await state.baseline : null + if (!isCurrent()) { + this.releaseCompletion(session, key) + return { cancelled: false } + } let requestError: unknown const interruptReceipt = session.connection - .request( - 'turn/interrupt', - { threadId: session.threadId, turnId }, - { timeoutMs: this.deps.requestTimeoutMs } - ) + .request('turn/interrupt', { threadId, turnId }, { timeoutMs: this.deps.requestTimeoutMs }) .then( () => true, (error: unknown) => { @@ -94,10 +113,31 @@ export class CodexStructuredTurnCancellation { ) const [acknowledged, terminated] = await Promise.all([ interruptReceipt, - this.terminate(session.connection, baseline) + targetsPrimaryTurn ? this.terminate(session.connection, baseline) : Promise.resolve(true) ]) if (terminated && acknowledged) { - this.releaseCompletion(session, turnId) + const completion = state.deferredCompletions.get(key) + let confirmationError: unknown + let promptAdmission = ADMITTED + try { + promptAdmission = onConfirmed?.() ?? ADMITTED + } catch (error) { + confirmationError = error + } + const completionAdmission = this.releaseCompletion(session, key, completion) + if (confirmationError) { + throw confirmationError + } + if (!promptAdmission.accepted) { + throw new Error( + `Codex prompt cancellation lifecycle was not admitted (${promptAdmission.reason})` + ) + } + if (onConfirmed && completion && !completionAdmission.accepted) { + throw new Error( + `Codex deferred turn completion lifecycle was not admitted (${completionAdmission.reason})` + ) + } return { cancelled: true } } if ( @@ -105,12 +145,12 @@ export class CodexStructuredTurnCancellation { !isCodexAppServerRequestError(requestError) && !isCodexAppServerUnsupportedError(requestError) ) { - this.releaseCompletion(session, turnId) + this.releaseCompletion(session, key) throw requestError } // A failed cancellation must not permanently divert the provider's later // completion for this turn. Let the normal completion path settle it. - this.releaseCompletion(session, turnId) + this.releaseCompletion(session, key) return { cancelled: false } } @@ -135,15 +175,13 @@ export class CodexStructuredTurnCancellation { private releaseCompletion( session: CodexSession, - turnId: string, - completion = this.state(session).deferredCompletions.get(turnId) - ): void { + key: string, + completion = this.state(session).deferredCompletions.get(key) + ): CodexJournalTranslationAdmission { const state = this.state(session) - state.blockedCompletions.delete(turnId) - state.deferredCompletions.delete(turnId) - if (completion) { - this.deps.emit(session, completion) - } + state.blockedCompletions.delete(key) + state.deferredCompletions.delete(key) + return completion ? this.deps.emit(session, completion) : ADMITTED } private state(session: CodexSession): TurnProcessState { diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index 58d12dfcf89..2ecd4e22cb6 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -2,6 +2,7 @@ import type { AgentJournalApprovalItem, AgentJournalItemBody, AgentJournalPromptOption, + AgentJournalQuestion, AgentJournalQuestionItem } from '../../../shared/agent-session-journal-types' import { @@ -11,6 +12,7 @@ import { } from './journal-payload-bounds' export const MAX_JOURNAL_PROMPT_OPTIONS = 64 +export const MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS = 4 const JOURNAL_PROMPT_OPTION_LIMITS = { inlineHeadBytes: 1024 } const JOURNAL_PROMPT_ID_MAX_BYTES = 1024 @@ -37,7 +39,12 @@ export function boundJournalStatusText(text: string): string { return boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } -function boundJournalPromptBody( +export function boundJournalPromptBody(body: AgentJournalApprovalItem): AgentJournalApprovalItem +export function boundJournalPromptBody(body: AgentJournalQuestionItem): AgentJournalQuestionItem +export function boundJournalPromptBody( + body: AgentJournalApprovalItem | AgentJournalQuestionItem +): AgentJournalApprovalItem | AgentJournalQuestionItem +export function boundJournalPromptBody( body: AgentJournalApprovalItem | AgentJournalQuestionItem ): AgentJournalApprovalItem | AgentJournalQuestionItem { if (body.kind === 'approval') { @@ -52,18 +59,41 @@ function boundJournalPromptBody( ...body, question: boundPromptText(body.question), options: boundPromptOptions(body.options), + ...(body.questions + ? { + questions: body.questions + .slice(0, MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS) + .map(boundPromptQuestion) + } + : {}), ...(body.freeTextQuestionId ? { freeTextQuestionId: boundPromptIdentifier(body.freeTextQuestionId) } : {}) } } +function boundPromptQuestion(question: AgentJournalQuestion): AgentJournalQuestion { + return { + id: boundPromptIdentifier(question.id), + question: boundPromptText(question.question), + ...(question.header === undefined ? {} : { header: boundPromptText(question.header) }), + multiSelect: question.multiSelect, + options: boundPromptOptions(question.options), + ...(question.freeTextQuestionId + ? { freeTextQuestionId: boundPromptIdentifier(question.freeTextQuestionId) } + : {}) + } +} + function boundPromptOptions( options: readonly AgentJournalPromptOption[] ): AgentJournalPromptOption[] { return options.slice(0, MAX_JOURNAL_PROMPT_OPTIONS).map((option) => ({ id: boundPromptIdentifier(option.id), - label: boundInlineText(option.label, JOURNAL_PROMPT_OPTION_LIMITS).text + label: boundInlineText(option.label, JOURNAL_PROMPT_OPTION_LIMITS).text, + ...(option.description === undefined + ? {} + : { description: boundInlineText(option.description, JOURNAL_PROMPT_OPTION_LIMITS).text }) })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 6b12ba61c6c..81ef7f79062 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -47,6 +47,13 @@ export class AgentSessionRewindRefusal extends AgentSessionAcquisitionRefusal { } } +export class AgentSessionPromptUnavailableError extends Error { + constructor(itemId: string) { + super(`The provider is no longer waiting on ${itemId}.`) + this.name = 'AgentSessionPromptUnavailableError' + } +} + /** * The provider's own root process was observed to exit, but its descendant tree * could not be verified. The lease keys on the root's pid and start time, so its @@ -194,6 +201,7 @@ export type StructuredAgentSessionAdapter = { sessionId: string turnId: string fence: number + prompt?: { itemId: string } }): Promise<{ cancelled: boolean }> stopBackgroundTasks?(input: { sessionId: string @@ -204,14 +212,15 @@ export type StructuredAgentSessionAdapter = { /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined - /** Fires the provider callback for an approval or a question. The wire calls - * this only after the durable compare-and-set won, so it runs exactly once. */ + /** Claims the live callback, commits the journal CAS while that claim is held, then answers it. + * A prompt cancel claims the same callback, so only one operation can commit. */ answerPrompt(input: { sessionId: string itemId: string kind: 'approval' | 'question' optionId: string fence: number + commit: () => Promise }): Promise setOption( input: StructuredAgentSessionSetOptionInput diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 85e02f2fea7..eec3841a08c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -22,7 +22,7 @@ import { } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' -import { settleStaleRunningTurnsOnAcquire } from './structured-agent-session-stale-turn-verdict' +import { settleStaleSessionStateOnAcquire } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { forgetStructuredAgentSession } from './structured-agent-session-host-lifetime' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -105,7 +105,7 @@ export function attachStructuredAgentSession( try { if (acquiredOwner) { // Before the drain: the buffered events are the new child's, never a stale row's. - await settleStaleRunningTurnsOnAcquire({ + await settleStaleSessionStateOnAcquire({ journal: attached.journal, sessionId, fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index 6082ab074f5..bbc1ec49d4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -2,12 +2,11 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -66,45 +65,43 @@ function adapter(): StructuredAgentSessionAdapter { } async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir: journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) + const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 100 } + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('seedGroupedQuestion requires an acquired session') + } + events.appendItem(identity, { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ] + }, + { + id: 'q2', + question: 'Host', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }) - const appended = await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 100 }, - { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { fence: 1 } - ) - return { itemId: appended.itemId, revision: appended.revision } + await host.flushStreamedEvents(SESSION) + const itemId = agentJournalItemKey(identity) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null + if (!appended) { + throw new Error('provider question was not written to the journal') + } + return { itemId, revision: appended.revision } } beforeEach(async () => { @@ -126,7 +123,7 @@ beforeEach(async () => { observedAt: NOW } })) - answerPrompt = vi.fn(async () => undefined) + answerPrompt = vi.fn(async ({ commit }) => commit()) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ store, @@ -145,9 +142,9 @@ afterEach(async () => { describe('grouped question admission', () => { it('admits renderer question-group payloads with child ids and multi-select answers', async () => { - const prompt = await seedGroupedQuestion() const attached = await host.attach(CALLER, attachParams()) expect(attached.ok).toBe(true) + const prompt = await seedGroupedQuestion() const optionId = encodeAgentSessionQuestionAnswers([ { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, { questionId: 'q2', optionIds: [], other: 'SSH host' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 9d50adaaa1a..abc468c9ea7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -37,6 +37,7 @@ export type StructuredAgentSessionMutationContext = { deps: StructuredAgentSessionHostDeps sessions: Map publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void + flushStreamedEvents: (sessionId: string) => Promise requireSession: (sessionId: string) => StructuredAgentSessionHostSession serialize: (sessionId: string, task: () => Promise) => Promise now: () => number @@ -57,6 +58,7 @@ function mutate( plan, journal: context.sessions.get(envelope.sessionId)?.journal, publish: (journal) => context.publish(envelope.sessionId, journal), + flushStreamedEvents: context.flushStreamedEvents, now: () => context.now() }) ) @@ -109,6 +111,7 @@ export function cancelStructuredAgentSessionTurn( turnId: string scope?: 'background-tasks' taskId?: string + prompt?: { itemId: string; expectedRevision: number } } ): Promise> { const command = context.deps.store.getRecord(params.envelope.sessionId)?.conversationCommand @@ -177,20 +180,28 @@ export async function settleStructuredAgentSessionLateDispatch( input: { sessionId: string clientMessageId: string - providerIdentity: AgentJournalItemIdentity - } + } & ({ providerIdentity: AgentJournalItemIdentity } | { state: 'rejected'; reason: string }) ): Promise { const session = context.sessions.get(input.sessionId) if (!session) { return } // The journal queue drains before close; the host queue would defer this past teardown. - await session.journal.resolveDispatch({ - clientMessageId: input.clientMessageId, - state: 'accepted', - providerIdentity: input.providerIdentity, - fence: session.fence - }) + await session.journal.resolveDispatch( + 'providerIdentity' in input + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: input.providerIdentity, + fence: session.fence + } + : { + clientMessageId: input.clientMessageId, + state: 'rejected', + reason: input.reason, + fence: session.fence + } + ) context.publish(input.sessionId, session.journal) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index c67a8fabf58..f14b1308810 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -3,10 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionDispatchOutcome, @@ -87,33 +87,28 @@ async function attach(): Promise { return store.getRecord(SESSION) } -/** Puts a pending approval in the journal BEFORE attach, which is the only way - * 1d can stage one: the adapter that would emit it is phase 2's. */ +/** Emits a pending approval through the acquired provider sink. */ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> { const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } - const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: THREAD } - }, - journalDir + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('seedApproval requires an acquired session') + } + events.appendItem(identity, { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }) - const appended = await journal.appendItem( - identity, - { - kind: 'approval', - title: 'Run the command?', - detail: null, - options: [{ id: optionId, label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { fence: 1 } - ) - return { itemId: appended.itemId, revision: appended.revision } + await host.flushStreamedEvents(SESSION) + const itemId = agentJournalItemKey(identity) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null + if (!appended) { + throw new Error('provider approval was not written to the journal') + } + return { itemId, revision: appended.revision } } beforeEach(async () => { @@ -138,7 +133,7 @@ beforeEach(async () => { releaseAcquisition = vi.fn(async () => true) dispatch = vi.fn(async () => accepted()) cancelTurn = vi.fn(async () => ({ cancelled: true })) - answerPrompt = vi.fn(async () => undefined) + answerPrompt = vi.fn(async ({ commit }) => commit()) setOption = vi.fn(async () => undefined) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) host = new StructuredAgentSessionHost({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index d53c3c30e50..a19735657d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -234,12 +234,117 @@ describe('cancel', () => { }) expect(cancelTurn).toHaveBeenCalledTimes(1) }) + + it.each([ + ['a missing prompt item', { itemId: 'missing-item', expectedRevision: 1 }], + ['a stale prompt revision', { itemId: 'seeded', expectedRevision: 2 }] + ])('refuses %s before interrupting the provider', async (_case, requestedPrompt) => { + await attach() + const prompt = await seedApproval() + const strictPrompt = { + ...requestedPrompt, + ...(requestedPrompt.itemId === 'seeded' ? { itemId: prompt.itemId } : {}) + } + const fields = { turnId: 'turn-1', prompt: strictPrompt } + + expect( + await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', fields), + ...fields + }) + ).toMatchObject({ ok: false }) + expect(cancelTurn).not.toHaveBeenCalled() + }) + + it('refuses cancellation after an answer has already resolved the prompt', async () => { + await attach() + const prompt = await seedApproval() + const answer = { + itemId: prompt.itemId, + expectedRevision: prompt.revision, + optionId: 'allow' + } + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', answer), + kind: 'approval', + ...answer + }) + const fields = { + turnId: 'turn-1', + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } + } + + expect( + await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', fields), + ...fields + }) + ).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_item_revision_stale' } + }) + expect(cancelTurn).not.toHaveBeenCalled() + }) + + it('records an unknown outcome when lifecycle draining fails and never interrupts on replay', async () => { + await attach() + const prompt = await seedApproval() + vi.spyOn(host, 'flushStreamedEvents').mockRejectedValueOnce(new Error('journal drain failed')) + const fields = { + turnId: 'turn-1', + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } + } + const params = { + envelope: envelope('agentSession.cancel', fields), + ...fields + } + + await expect(host.cancel(CALLER, params)).rejects.toThrow('journal drain failed') + expect(await host.cancel(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + }) + + it('records an unknown outcome when strict prompt interruption throws and never retries it', async () => { + await attach() + const prompt = await seedApproval() + cancelTurn.mockRejectedValueOnce(new Error('interrupt receipt lost')) + const fields = { + turnId: 'turn-1', + prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision } + } + const params = { + envelope: envelope('agentSession.cancel', fields), + ...fields + } + + await expect(host.cancel(CALLER, params)).rejects.toThrow('interrupt receipt lost') + expect(await host.cancel(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ + ok: true, + page: { + items: [ + expect.objectContaining({ + body: expect.objectContaining({ + resolution: expect.objectContaining({ state: 'pending' }) + }) + }) + ] + } + }) + }) }) describe('respondToPrompt', () => { it('commits the answer before the provider callback', async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } const result = await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:approval', fields), @@ -254,8 +359,8 @@ describe('respondToPrompt', () => { }) it('refuses a second answer to one prompt and says which answer won', async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:approval', fields), @@ -281,8 +386,8 @@ describe('respondToPrompt', () => { }) it('refuses an option the prompt does not offer', async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'deny' } expect( await host.respondToPrompt(CALLER, { @@ -295,8 +400,8 @@ describe('respondToPrompt', () => { }) it("does not turn a recorded refusal into another client's successful answer", async () => { - const prompt = await seedApproval() await attach() + const prompt = await seedApproval() const rejectedFields = { itemId: prompt.itemId, expectedRevision: prompt.revision, @@ -326,9 +431,12 @@ describe('respondToPrompt', () => { }) it('keeps the answer and reports it undelivered when the provider callback throws', async () => { - const prompt = await seedApproval() await attach() - answerPrompt.mockRejectedValueOnce(new Error('pipe closed')) + const prompt = await seedApproval() + answerPrompt.mockImplementationOnce(async ({ commit }) => { + await commit() + throw new Error('pipe closed') + }) const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } const result = await host.respondToPrompt(CALLER, { envelope: envelope('agentSession.respondTo:approval', fields), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index c60d9e5db26..8fcdef95480 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -267,6 +267,7 @@ export class StructuredAgentSessionHost { deps: this.deps, sessions: this.sessions, publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), + flushStreamedEvents: this.flushStreamedEvents, requireSession: (sessionId) => this.requireSession(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), now: () => this.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 293f6ab2d8c..1874f7ee1c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent @@ -204,6 +205,27 @@ describe('settling a send the provider proves it received after the ack window', expect(dispatch).toHaveBeenCalledTimes(1) }) + it('settles a provider-cancelled queued send as rejected', async () => { + dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const params = sendParams('queued behind the active turn') + await host.send(CALLER, params) + + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: params.envelope.clientOperationId, + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + + expect(submissions()).toMatchObject([ + { + clientMessageId: params.envelope.clientOperationId, + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + } + ]) + }) + it('accepts from the durable echo row when the direct settlement write fails', async () => { dispatch.mockResolvedValueOnce({ state: 'admitted' }) const params = sendParams('settle from provider echo') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index c59a538ead7..34e8004cc81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -45,6 +45,7 @@ export type AgentSessionMutationRequest = { /** Journal of the attached session; absent when this host holds none. */ journal: AgentSessionJournal | undefined publish: (journal: AgentSessionJournal) => void + flushStreamedEvents: (sessionId: string) => Promise now: () => number } @@ -147,6 +148,7 @@ function turnContext( .then(() => undefined), resolvedBy: request.callerKey, publish: () => request.publish(journal), + flushStreamedEvents: () => request.flushStreamedEvents(request.envelope.sessionId), now: () => request.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 96c50036701..d8d2876d272 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -96,20 +96,23 @@ export function cancelPlan(params: { turnId: string scope?: 'background-tasks' taskId?: string + prompt?: { itemId: string; expectedRevision: number } }): MutationPlan { return { method: 'agentSession.cancel', fields: { turnId: params.turnId, ...(params.scope ? { scope: params.scope } : {}), - ...(params.taskId ? { taskId: params.taskId } : {}) + ...(params.taskId ? { taskId: params.taskId } : {}), + ...(params.prompt ? { prompt: params.prompt } : {}) }, run: (ctx) => performCancel(ctx, { clientOperationId: params.envelope.clientOperationId, turnId: params.turnId, ...(params.scope ? { scope: params.scope } : {}), - ...(params.taskId ? { taskId: params.taskId } : {}) + ...(params.taskId ? { taskId: params.taskId } : {}), + ...(params.prompt ? { prompt: params.prompt } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled instead. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts new file mode 100644 index 00000000000..aeb2c41095f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -0,0 +1,212 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const PROMPT_IDENTITY = { + provider: 'codex' as const, + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 1 +} + +const journals = createTrackedJournalOpener() +let root: string | null = null + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: string }> { + root = await mkdtemp(join(tmpdir(), 'orca-prompt-cancel-')) + const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const item = await journal.appendItem( + PROMPT_IDENTITY, + { + kind: 'approval', + title: 'Approve?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + { fence: 1 } + ) + return { journal, itemId: item.itemId } +} + +function context( + journal: AgentSessionJournal, + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'], + flushStreamedEvents: () => Promise +): AgentSessionTurnContext { + return { + sessionId: 'session-1', + journal, + fence: 1, + adapter: { cancelTurn } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + flushStreamedEvents, + now: () => 1 + } +} + +describe('performCancel for a pending prompt', () => { + it('refuses a stale prompt revision before reaching the provider', async () => { + const { journal, itemId } = await pendingPrompt() + const cancelTurn = vi.fn(async () => ({ cancelled: true })) + const flush = vi.fn(async () => undefined) + + const result = await performCancel(context(journal, cancelTurn, flush), { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 2 } + }) + + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_item_revision_stale', currentRevision: 1 } + }) + expect(cancelTurn).not.toHaveBeenCalled() + expect(flush).not.toHaveBeenCalled() + }) + + it('drains terminal lifecycle before recording a confirmed cancellation', async () => { + const { journal, itemId } = await pendingPrompt() + const order: string[] = [] + const cancelTurn = vi.fn(async () => { + order.push('interrupt') + return { cancelled: true } + }) + const flush = vi.fn(async () => { + order.push('lifecycle') + const current = journal.snapshot().items.find((item) => item.itemId === itemId)! + if (current.body.kind !== 'approval') { + throw new Error('expected approval prompt') + } + await journal.appendItem( + PROMPT_IDENTITY, + { + ...current.body, + resolution: { + state: 'cancelled', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + { fence: 1 } + ) + }) + + await expect( + performCancel(context(journal, cancelTurn, flush), { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + }) + ).resolves.toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: true } }) + + expect(order).toEqual(['interrupt', 'lifecycle']) + expect(cancelTurn).toHaveBeenCalledWith({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 1, + prompt: { itemId } + }) + expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), + { kind: 'status', text: 'Cancellation requested.' } + ]) + }) + + it('keeps the callback answerable when interruption is declined', async () => { + const { journal, itemId } = await pendingPrompt() + const flush = vi.fn(async () => undefined) + + await expect( + performCancel( + context(journal, async () => ({ cancelled: false }), flush), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).resolves.toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: false } }) + + expect(flush).not.toHaveBeenCalled() + expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'pending' }) }), + { kind: 'status', text: 'The provider had already finished this turn.' } + ]) + }) + + it('propagates an unconfirmed adapter failure and leaves the prompt pending', async () => { + const { journal, itemId } = await pendingPrompt() + const flush = vi.fn(async () => undefined) + + await expect( + performCancel( + context( + journal, + async () => { + throw new Error('interrupt receipt lost') + }, + flush + ), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).rejects.toThrow('interrupt receipt lost') + + expect(flush).not.toHaveBeenCalled() + expect(journal.snapshot().items.map((item) => item.body)).toEqual([ + expect.objectContaining({ resolution: expect.objectContaining({ state: 'pending' }) }) + ]) + }) + + it('surfaces a lifecycle drain failure after the provider confirms interruption', async () => { + const { journal, itemId } = await pendingPrompt() + const flush = vi.fn(async () => { + throw new Error('journal drain failed') + }) + + await expect( + performCancel( + context(journal, async () => ({ cancelled: true }), flush), + { + clientOperationId: 'cancel-1', + turnId: 'turn-1', + prompt: { itemId, expectedRevision: 1 } + } + ) + ).rejects.toThrow('journal drain failed') + expect(journal.snapshot().items).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts new file mode 100644 index 00000000000..7f71a9c28ce --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -0,0 +1,59 @@ +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +type PendingPromptBody = Extract + +export type PendingPromptValidation = + | { ok: true; item: AgentJournalRenderItem; prompt: PendingPromptBody } + | { ok: false; refusal: AgentSessionWireRefusal } + +function invalid(message: string): PendingPromptValidation { + return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +} + +export function validatePendingPrompt( + ctx: Pick, + input: { + itemId: string + expectedRevision: number + kind?: 'approval' | 'question' + } +): PendingPromptValidation { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) + if (!item) { + return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + } + const prompt = item.body.kind === 'approval' || item.body.kind === 'question' ? item.body : null + if (!prompt || (input.kind !== undefined && prompt.kind !== input.kind)) { + return invalid( + `Item ${input.itemId} is not a pending${input.kind ? ` ${input.kind}` : ' prompt'}.` + ) + } + if (item.revision !== input.expectedRevision) { + return { + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + message: `Item ${input.itemId} has moved on.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + if (prompt.resolution.state !== 'pending') { + return { + ok: false, + refusal: { + code: 'agent_session_already_resolved', + message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + return { ok: true, item, prompt } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index cb7cbb1f20b..c301845c7c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -38,6 +38,7 @@ export async function rewindStructuredAgentSession( envelope: params.envelope, journal: context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), + flushStreamedEvents: context.flushStreamedEvents, now: context.now, plan: { method: 'agentSession.rewind', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 477ed785918..0f75c9a3322 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -63,6 +63,7 @@ describe('structured send idempotency', () => { persistOptions: async () => undefined, resolvedBy: 'caller', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 }, input @@ -103,6 +104,7 @@ describe('structured send idempotency', () => { persistOptions: async () => undefined, resolvedBy: 'caller', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } const input = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 8ffb7acf6ce..d0c9f04c27f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -4,7 +4,7 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journ import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { runningTurnLifecycleRevisions, - settleStaleRunningTurnsOnAcquire, + settleStaleSessionStateOnAcquire, turnVerdictFromDeathEvidence } from './structured-agent-session-stale-turn-verdict' @@ -43,6 +43,32 @@ function legacyLifecycleItem(turnId: string, startedAt: number): AgentJournalRen } } +function promptItem(state: 'pending' | 'resolved', sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: `approval-${state}` + }), + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'approval', + title: 'Approve?', + detail: null, + options: [], + resolution: { + state, + selectedOptionId: state === 'resolved' ? 'allow' : null, + resolvedBy: state === 'resolved' ? 'client-1' : null, + resolvedAt: state === 'resolved' ? 10 : null + } + } + } +} + describe('turn verdict from death evidence', () => { it('earns an end time only from an observed exit', () => { expect( @@ -105,7 +131,7 @@ describe('running turn lifecycle revisions', () => { }) }) -describe('stale running turns on a cold acquire', () => { +describe('stale session state on a cold acquire', () => { function journalWith(items: AgentJournalRenderItem[]) { const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 9 })) const journal = { @@ -123,7 +149,7 @@ describe('stale running turns on a cold acquire', () => { ]) await expect( - settleStaleRunningTurnsOnAcquire({ + settleStaleSessionStateOnAcquire({ journal, sessionId: 'session-1', fence: 14, @@ -132,7 +158,7 @@ describe('stale running turns on a cold acquire', () => { ).resolves.toBe(1) expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({ - settlementId: 'stale-turn:session-1:14:generation-2', + settlementId: 'stale-session:session-1:14:generation-2', fence: 14, recovered: true, mutations: [ @@ -145,12 +171,53 @@ describe('stale running turns on a cold acquire', () => { }) }) + it('cancels only prompts whose callbacks were lost with the prior owner', async () => { + const pending = promptItem('pending', 1) + const resolved = promptItem('resolved', 2) + const { journal, appendLifecycleBatch } = journalWith([pending, resolved]) + + await expect( + settleStaleSessionStateOnAcquire({ + journal, + sessionId: 'session-1', + fence: 14, + acquisitionGeneration: 'generation-2' + }) + ).resolves.toBe(1) + + expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({ + settlementId: 'stale-session:session-1:14:generation-2', + fence: 14, + recovered: true, + mutations: [ + { + kind: 'item', + identity: { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'approval-pending' + }, + body: { + ...pending.body, + resolution: { + state: 'cancelled', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + }) + }) + it('writes nothing when no turn is running and keys on the journal position without a generation', async () => { const idle = journalWith([ lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }) ]) await expect( - settleStaleRunningTurnsOnAcquire({ + settleStaleSessionStateOnAcquire({ journal: idle.journal, sessionId: 'session-1', fence: 14, @@ -160,14 +227,14 @@ describe('stale running turns on a cold acquire', () => { expect(idle.appendLifecycleBatch).not.toHaveBeenCalled() const running = journalWith([lifecycleItem('turn-2', 'running', 2)]) - await settleStaleRunningTurnsOnAcquire({ + await settleStaleSessionStateOnAcquire({ journal: running.journal, sessionId: 'session-1', fence: 14, acquisitionGeneration: null }) expect(running.appendLifecycleBatch).toHaveBeenCalledWith( - expect.objectContaining({ settlementId: 'stale-turn:session-1:14:seq-8' }) + expect.objectContaining({ settlementId: 'stale-session:session-1:14:seq-8' }) ) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 940b0c8be17..78609bd199b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -17,6 +17,7 @@ import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-re import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' export type StructuredAgentSessionTurnVerdict = | { state: 'interrupted'; completedAt: number } @@ -58,6 +59,28 @@ export function runningTurnLifecycleRevisions( return revisions } +function staleSessionLifecycleRevisions( + items: readonly AgentJournalRenderItem[] +): JournalLifecycleMutationInput[] { + const revisions: JournalLifecycleMutationInput[] = [] + for (const item of items) { + const identity = parseAgentJournalItemKey(item.itemId) + if (!identity) { + continue + } + const cancelled = + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ? cancelledJournalPromptBody(item.body) + : null + if (cancelled) { + revisions.push({ kind: 'item', identity, body: cancelled }) + } + } + revisions.push(...runningTurnLifecycleRevisions(items, UNVERIFIABLE_TURN_VERDICT)) + return revisions +} + function settledLifecycle( lifecycle: AgentJournalTurnLifecycle, verdict: StructuredAgentSessionTurnVerdict @@ -77,19 +100,16 @@ function settledLifecycle( /** A running row found when a NEW child is acquired belongs to a generation whose exit nobody * observed. Must run before that child's buffered events land, or a live turn would be judged. */ -export async function settleStaleRunningTurnsOnAcquire(input: { +export async function settleStaleSessionStateOnAcquire(input: { journal: AgentSessionJournal sessionId: string fence: number acquisitionGeneration: string | null }): Promise { const { journal } = input - const revisions = runningTurnLifecycleRevisions( - journal.snapshot().items, - UNVERIFIABLE_TURN_VERDICT - ) + const revisions = staleSessionLifecycleRevisions(journal.snapshot().items) const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` - const settlementId = `stale-turn:${input.sessionId}:${input.fence}:${generation}` + const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` for (const chunk of partitionJournalLifecycleMutations(settlementId, revisions)) { await journal.appendLifecycleBatch({ settlementId: chunk.settlementId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 26ad85b5cfb..6ac29cab0ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -3,28 +3,17 @@ import { decodeAgentSessionQuestionAnswers, isValidAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' -import type { - AgentJournalItemBody, - AgentJournalQuestion, - AgentJournalResolution -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalResolution } from '../../../shared/agent-session-journal-types' import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire' import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies' +import { AgentSessionPromptUnavailableError } from './structured-agent-session-adapter' +import { validatePendingPrompt } from './structured-agent-session-prompt-state' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' function invalid(message: string): TurnOutcome { return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } } -function promptBodyOf(body: AgentJournalItemBody): { - options: readonly { id: string }[] - freeTextQuestionId?: string - questions?: AgentJournalQuestion[] - resolution: AgentJournalResolution -} | null { - return body.kind === 'approval' || body.kind === 'question' ? body : null -} - export async function performPrompt( ctx: AgentSessionTurnContext, input: { @@ -34,50 +23,22 @@ export async function performPrompt( kind: 'approval' | 'question' } ): Promise> { - const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) - if (!item) { - return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) - } - const prompt = promptBodyOf(item.body) - if (!prompt || item.body.kind !== input.kind) { - return invalid(`Item ${input.itemId} is not a pending ${input.kind}.`) - } - if (item.revision !== input.expectedRevision) { - return { - ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - message: `Item ${input.itemId} has moved on.`, - currentRevision: item.revision, - resolution: prompt.resolution - } - } - } - if (prompt.resolution.state !== 'pending') { - return { - ok: false, - refusal: { - code: 'agent_session_already_resolved', - message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, - currentRevision: item.revision, - resolution: prompt.resolution - } - } + const validated = validatePendingPrompt(ctx, input) + if (!validated.ok) { + return validated } + const { prompt } = validated + const question = prompt.kind === 'question' ? prompt : null const freeText = decodeCodexQuestionOptionId(input.optionId) const acceptsFreeText = - item.body.kind === 'question' && - prompt.freeTextQuestionId !== undefined && - freeText?.questionId === prompt.freeTextQuestionId && + question?.freeTextQuestionId !== undefined && + freeText?.questionId === question.freeTextQuestionId && freeText.answer.trim().length > 0 - const grouped = - item.body.kind === 'question' && prompt.questions - ? decodeAgentSessionQuestionAnswers(input.optionId) - : null + const grouped = question?.questions ? decodeAgentSessionQuestionAnswers(input.optionId) : null const acceptsGrouped = grouped !== null && - prompt.questions !== undefined && - isValidAgentSessionQuestionAnswers(prompt.questions, grouped) + question?.questions !== undefined && + isValidAgentSessionQuestionAnswers(question.questions, grouped) if ( !acceptsFreeText && !acceptsGrouped && @@ -96,24 +57,32 @@ export async function performPrompt( resolvedBy: ctx.resolvedBy, resolvedAt: ctx.now() } - const appended = await ctx.journal.appendItem( - identity, - { ...item.body, resolution }, - { - fence: ctx.fence - } - ) - ctx.publish() - + const committed: { item?: Awaited> } = {} try { await ctx.adapter.answerPrompt({ sessionId: ctx.sessionId, itemId: input.itemId, kind: input.kind, optionId: input.optionId, - fence: ctx.fence + fence: ctx.fence, + commit: async () => { + committed.item = await ctx.journal.appendItem( + identity, + { ...prompt, resolution }, + { + fence: ctx.fence + } + ) + ctx.publish() + } }) } catch (error) { + if (!committed.item && error instanceof AgentSessionPromptUnavailableError) { + return invalid(error.message) + } + if (!committed.item) { + throw error + } await ctx.journal.appendItem( { provider: 'orca', clientMessageId: `${input.itemId}#delivery` }, { @@ -126,6 +95,10 @@ export async function performPrompt( ) ctx.publish() } + const appended = committed.item + if (!appended) { + throw new Error(`Provider adapter did not commit prompt ${input.itemId}.`) + } return { ok: true, value: { itemId: appended.itemId, revision: appended.revision, resolution } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index aa0785da31a..59b1e74898c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -54,6 +54,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } @@ -101,6 +102,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } @@ -133,6 +135,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } @@ -164,6 +167,7 @@ describe('performCancel', () => { persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), + flushStreamedEvents: async () => undefined, now: () => 1 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 4c275ecd738..c666047e1e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -21,6 +21,7 @@ import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { validatePendingPrompt } from './structured-agent-session-prompt-state' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' @@ -34,6 +35,8 @@ export type AgentSessionTurnContext = { /** Opaque client identity recorded as the resolver of a prompt. */ resolvedBy: string publish: () => void + /** Drains provider lifecycle already accepted by the execution host. */ + flushStreamedEvents: () => Promise now: () => number } @@ -186,8 +189,15 @@ export async function performCancel( turnId: string scope?: 'background-tasks' taskId?: string + prompt?: { itemId: string; expectedRevision: number } } ): Promise> { + if (input.prompt) { + const validated = validatePendingPrompt(ctx, input.prompt) + if (!validated.ok) { + return validated + } + } let cancelled = false let note = 'Cancellation requested.' try { @@ -203,17 +213,24 @@ export async function performCancel( await ctx.adapter.cancelTurn({ sessionId: ctx.sessionId, turnId: input.turnId, - fence: ctx.fence + fence: ctx.fence, + ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) }) ).cancelled if (!cancelled) { note = 'The provider had already finished this turn.' } } catch (error) { + if (input.prompt) { + throw error + } note = `Cancellation was not confirmed: ${ error instanceof Error ? error.message : String(error) }` } + if (cancelled && input.prompt) { + await ctx.flushStreamedEvents() + } if (input.scope) { return { ok: true, value: { turnId: input.turnId, cancelled } } } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 9a2bff7c9fc..44131886f5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -54,6 +54,7 @@ export function runStructuredConversationCommand( envelope, journal: context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), + flushStreamedEvents: context.flushStreamedEvents, now: context.now, plan: { method: 'agentSession.conversationCommand', diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index c2d46b09818..73e3113aea8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -606,6 +606,19 @@ describe('method routing', () => { expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params) }) + it('routes strict prompt identity through cancellation', async () => { + const params = { + envelope: envelope(), + turnId: 'turn-1', + prompt: { itemId: 'prompt-1', expectedRevision: 2 } + } + + const response = await call('agentSession.cancel', params, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params) + }) + it('routes the structured handoff mutation through the host', async () => { const response = await call('agentSession.requestHandoff', { envelope: envelope(), @@ -648,6 +661,17 @@ describe('parameter validation', () => { turnId: 'turn-1', taskId: 'task-2' }) + await rejects('agentSession.cancel', { + envelope: envelope(), + turnId: 'background-tasks', + scope: 'background-tasks', + prompt: { itemId: 'prompt-1', expectedRevision: 1 } + }) + await rejects('agentSession.cancel', { + envelope: envelope(), + turnId: 'turn-1', + prompt: { itemId: 'prompt-1', expectedRevision: 0 } + }) expect(hostCalls.cancel).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index e437739a58b..92643fcae57 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -7,7 +7,6 @@ // reads is module-level for the same reason the registry is — the runtime // service is already far past its size budget. -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { existsSync } from 'node:fs' import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -247,11 +246,9 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + const onDispatchSettledLate = ( + settlement: Parameters[0] + ): void => { void host?.settleLateDispatch(settlement).catch((error) => deps.onError?.({ scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 847e50ace06..823f01a3fe5 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -184,7 +184,10 @@ describe('NativeChatStructuredSession', () => { expect(screen.queryByTestId('structured-composer')).toBeNull() act(() => mocks.questionCardProps?.onCancel()) - expect(mocks.cancel).toHaveBeenCalledWith('turn-question') + expect(mocks.cancel).toHaveBeenCalledWith('turn-question', { + itemId: 'legacy-question-item', + expectedRevision: 1 + }) expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) mocks.promptItems = [] @@ -250,7 +253,10 @@ describe('NativeChatStructuredSession', () => { expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false) act(() => mocks.approvalCardProps?.onCancel?.()) - expect(mocks.cancel).toHaveBeenCalledWith('turn-approval') + expect(mocks.cancel).toHaveBeenCalledWith('turn-approval', { + itemId: 'approval-item', + expectedRevision: 1 + }) }) // Every background-task test mounts the same local Claude session; only the ids diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 867208240a8..3908c42fa28 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -115,6 +115,14 @@ export function NativeChatStructuredSession( const activeStoppingBackgroundTasks = stoppingBackgroundTasks?.sessionId === props.sessionId ? stoppingBackgroundTasks : null const prompt = controller.prompts[0] ?? null + const cancelPrompt = () => { + if (controller.turnId && prompt) { + void controller.cancel(controller.turnId, { + itemId: prompt.itemId, + expectedRevision: prompt.revision + }) + } + } useNativeChatComposerRevealFocus({ rootRef, composerRef, @@ -246,11 +254,7 @@ export function NativeChatStructuredSession( })) }} onChoose={(optionId) => void controller.respond(prompt, optionId)} - onCancel={() => { - if (controller.turnId) { - void controller.cancel(controller.turnId) - } - }} + onCancel={cancelPrompt} /> ) : null} {prompt && questionBody ? ( @@ -300,11 +304,7 @@ export function NativeChatStructuredSession( void controller.respond(prompt, optionId) } }} - onCancel={() => { - if (controller.turnId) { - void controller.cancel(controller.turnId) - } - }} + onCancel={cancelPrompt} /> ) : null} {retryableOutboxEntry ? ( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx new file mode 100644 index 00000000000..149fe7b22f0 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + promptCancelSupported: vi.fn(), + operationId: vi.fn(() => 'operation-1') +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionPromptCancel: mocks.promptCancelSupported +})) +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items, + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: mocks.operationId, + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + blockedClientMessageId: null, + error: null, + send: vi.fn(), + retry: vi.fn() + }) +})) + +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { useStructuredAgentSession } from './use-structured-agent-session' + +let items: AgentJournalRenderItem[] = [] +const target = { kind: 'local' } as const + +function pendingApproval(): AgentJournalRenderItem { + return { + itemId: 'approval-1', + revision: 2, + sequence: 2, + observedAt: 2, + body: { + kind: 'approval', + title: 'Allow Bash?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } +} + +function runningTurn(): AgentJournalRenderItem { + return { + itemId: 'turn-status', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + text: 'Waiting', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } + } +} + +describe('desktop structured prompt cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + items = [runningTurn(), pendingApproval()] + mocks.promptCancelSupported.mockResolvedValue(false) + mocks.call.mockResolvedValue({ ok: true, value: { turnId: 'turn-1', cancelled: true } }) + }) + + it('sends item identity and revision on capable hosts', async () => { + mocks.promptCancelSupported.mockResolvedValue(true) + const { result } = renderHook(() => + useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true }) + ) + + await act(async () => { + await result.current.cancel('turn-1', { itemId: 'approval-1', expectedRevision: 2 }) + }) + + expect(mocks.promptCancelSupported).toHaveBeenCalledWith(target) + const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.cancel') + expect(call?.[2]).toMatchObject({ + turnId: 'turn-1', + prompt: { itemId: 'approval-1', expectedRevision: 2 } + }) + }) + + it('omits strict prompt identity on old hosts', async () => { + const { result } = renderHook(() => + useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true }) + ) + + await act(async () => { + await result.current.cancel('turn-1', { itemId: 'approval-1', expectedRevision: 2 }) + }) + + const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.cancel') + expect(call?.[2]).toMatchObject({ turnId: 'turn-1' }) + expect(call?.[2]).not.toHaveProperty('prompt') + }) + + it('keeps ordinary composer stop turn-only without a capability probe', async () => { + const { result } = renderHook(() => + useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true }) + ) + + await act(async () => { + await result.current.cancel('turn-1') + }) + + expect(mocks.promptCancelSupported).not.toHaveBeenCalled() + const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.cancel') + expect(call?.[2]).toMatchObject({ turnId: 'turn-1' }) + expect(call?.[2]).not.toHaveProperty('prompt') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 31e8ff34364..020647de089 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -28,7 +28,10 @@ import { hasUnansweredStructuredAgentSessionDispatch } from '../../../../shared/structured-agent-session-projection' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + callStructuredAgentSession, + supportsStructuredAgentSessionPromptCancel +} from '@/runtime/structured-agent-session-client' import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' import { @@ -44,6 +47,8 @@ import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/stru export type { StructuredPromptItem } from './structured-agent-session-message-projection' +type StructuredPromptCancelTarget = { itemId: string; expectedRevision: number } + export function useStructuredAgentSession(args: { sessionId: string target: RuntimeClientTarget @@ -271,7 +276,16 @@ export function useStructuredAgentSession(args: { turnActivity, backgroundTasks, turnId, - cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }), + cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => { + // Capability negotiation must complete before mutate constructs the payload + // fingerprint and operation id: older hosts reject the strict prompt field. + const promptSupported = + prompt !== undefined && (await supportsStructuredAgentSessionPromptCancel(target)) + return mutate('agentSession.cancel', 'agentSession.cancel', { + turnId, + ...(promptSupported ? { prompt } : {}) + }) + }, stopBackgroundTask: (taskId?: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId: 'background-tasks', diff --git a/src/renderer/src/runtime/structured-agent-session-client.test.ts b/src/renderer/src/runtime/structured-agent-session-client.test.ts index 4d3ed6960c6..d0f65fd526e 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.test.ts @@ -1,12 +1,17 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_SESSION_REWIND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), call: vi.fn(), - supportsCapability: vi.fn() + supportsCapability: vi.fn(), + readLocalCapabilities: vi.fn(), + ensureLocalCapabilities: vi.fn() })) vi.mock('./runtime-environment-revision', () => ({ @@ -17,12 +22,50 @@ vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.call, runtimeEnvironmentSupportsCapability: mocks.supportsCapability })) +vi.mock('./local-runtime-capabilities', () => ({ + readLocalRuntimeCapabilitiesOrUnknown: mocks.readLocalCapabilities, + ensureLocalRuntimeCapabilities: mocks.ensureLocalCapabilities +})) import { callStructuredAgentSession, - subscribeStructuredAgentSession + subscribeStructuredAgentSession, + supportsStructuredAgentSessionPromptCancel } from './structured-agent-session-client' +describe('structured prompt cancellation capability', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.readLocalCapabilities.mockReturnValue(null) + mocks.ensureLocalCapabilities.mockResolvedValue(null) + }) + + it('uses the local status cache and fails closed until the host answers', async () => { + const target = { kind: 'local' } as const + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(false) + mocks.ensureLocalCapabilities.mockResolvedValue([ + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY + ]) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(true) + mocks.readLocalCapabilities.mockReturnValue([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY]) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(true) + expect(mocks.ensureLocalCapabilities).toHaveBeenCalledTimes(2) + }) + + it('checks the selected remote runtime and downgrades on absent or failed capability', async () => { + const target = { kind: 'environment', environmentId: 'ssh-env-1' } as const + mocks.supportsCapability.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(true) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(false) + mocks.supportsCapability.mockRejectedValue(new Error('Disconnected')) + await expect(supportsStructuredAgentSessionPromptCancel(target)).resolves.toBe(false) + expect(mocks.supportsCapability).toHaveBeenCalledWith( + 'ssh-env-1', + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY + ) + }) +}) + describe('callStructuredAgentSession rewind capability', () => { const target = { kind: 'environment', environmentId: 'env-1' } as const const params = { itemId: 'item-1', expectedEpoch: 'epoch-1' } diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index c769ec302c1..5ccbe6360f3 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -4,12 +4,39 @@ import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' -import { AGENT_SESSION_REWIND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability, type RuntimeClientTarget } from './runtime-rpc-client' +import { + ensureLocalRuntimeCapabilities, + readLocalRuntimeCapabilitiesOrUnknown +} from './local-runtime-capabilities' +/** Read the prompt-cancel capability through the runtime's existing status cache. + * A failed/unknown probe is treated as legacy so strict prompt fields are never + * sent before the host has proved it understands them. */ +export async function supportsStructuredAgentSessionPromptCancel( + target: RuntimeClientTarget +): Promise { + try { + if (target.kind === 'local') { + const known = readLocalRuntimeCapabilitiesOrUnknown() + const capabilities = known ?? (await ensureLocalRuntimeCapabilities()) + return capabilities?.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) === true + } + return await runtimeEnvironmentSupportsCapability( + target.environmentId, + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY + ) + } catch { + return false + } +} export async function callStructuredAgentSession( target: RuntimeClientTarget, diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index fb495f7cb8c..b1ad68f0fa3 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -75,6 +75,8 @@ export type AgentSessionTurnActivity = { text: string } +export const AGENT_SESSION_ID_MAX_LENGTH = 512 + /** Backward paging is the client's normal read; 40 matches the page size the * mobile list renders without a visible fill-in. */ export const AGENT_SESSION_HISTORY_DEFAULT_LIMIT = 40 diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 99908de9b84..6afdf0b3b62 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -175,6 +175,10 @@ export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' export const AGENT_SESSION_TURN_ITEM_CAPABILITY = 'agent-session.turn-item.v1' as const export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = 'agent-session.background-task-stop.v1' as const +// Why: agentSession.cancel has a strict schema, so clients must not send prompt identity to an +// older host that would reject the whole cancellation instead of falling back to turn stop. +export const AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY = + 'agent-session.prompt-cancel.v1' as const // Why: the host now publishes rows for work that is live inside a turn, and such // a row carries `stoppable: false` because no targeted stop can reach it. A // reader that predates the field draws a per-row Stop on every row it is given, @@ -294,6 +298,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, + AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 7d2c73afb66..edbda12d6d9 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -2,11 +2,12 @@ import { z } from 'zod' import { isAgentSessionId } from '../agent-session-record' import { normalizeExecutionHostId } from '../execution-host' import { + AGENT_SESSION_ID_MAX_LENGTH, AGENT_SESSION_HISTORY_DIRECTIONS, AGENT_SESSION_HISTORY_MAX_LIMIT } from '../agent-session-wire' -export const MAX_ID_LENGTH = 512 +export const MAX_ID_LENGTH = AGENT_SESSION_ID_MAX_LENGTH // Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. export const MAX_RESPONSE_OPTION_ID_LENGTH = 1024 @@ -164,11 +165,23 @@ export const CancelParams = z envelope: MutationEnvelope, turnId: Identifier('Invalid turn id'), scope: z.literal('background-tasks').optional(), - taskId: Identifier('Invalid task id').optional() + taskId: Identifier('Invalid task id').optional(), + prompt: z + .object({ + itemId: Identifier('Invalid item id'), + expectedRevision: z.number().int().positive() + }) + .strict() + .optional() }) .strict() - .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', { - message: 'A task id requires background-task scope' + .superRefine((value, ctx) => { + if (value.taskId !== undefined && value.scope !== 'background-tasks') { + ctx.addIssue({ code: 'custom', message: 'A task id requires background-task scope' }) + } + if (value.prompt !== undefined && value.scope === 'background-tasks') { + ctx.addIssue({ code: 'custom', message: 'A prompt cannot use background-task scope' }) + } }) export const RespondParams = z diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 0b2aecfb67e..14e197325d8 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -26,6 +26,9 @@ export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' export const DISPATCH_REJECTED_QUEUE_FULL = 'claude structured dispatch queue is full' export const DISPATCH_REJECTED_CODEX_QUEUE_FULL = 'codex structured dispatch queue is full' +/** The provider confirmed a queued frame was withdrawn before execution. */ +export const DISPATCH_REJECTED_CANCELLED = 'provider_cancelled_before_start' + export function dispatchWriteFailureReason(error: unknown): string { const detail = error instanceof Error ? error.message : String(error) return `${DISPATCH_REJECTED_WRITE_FAILED}: ${detail}` @@ -50,6 +53,7 @@ export function dispatchRejectionReasonIsInternal(reason: string | null | undefi return ( dispatchRejectionWasTransportWriteFailure(reason) || reason === DISPATCH_REJECTED_QUEUE_FULL || - reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL + reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL || + reason === DISPATCH_REJECTED_CANCELLED ) } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 0030f17a5b0..80c862f9f02 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -2,6 +2,7 @@ import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-se import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' import type { AgentSessionWireRefusalCode } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' export type StructuredAgentSessionOutboxState = 'queued' | 'dispatching' | 'unconfirmed' @@ -102,6 +103,12 @@ export function reconcileStructuredAgentSessionOutbox( if (submission?.dispatchState === 'accepted') { return [] } + if ( + submission?.dispatchState === 'rejected' && + submission.reason === DISPATCH_REJECTED_CANCELLED + ) { + return [] + } if (submission?.dispatchState === 'pending') { return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }] } diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 4ab5c107db0..b567c2a8dd2 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -8,11 +8,13 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { dispatchWriteFailureReason, + DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_QUEUE_FULL } from './structured-agent-session-dispatch-rejection' import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' import { createStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' @@ -55,6 +57,15 @@ function notice(reason: string | null): string | null { } describe('what a rejection shows the user', () => { + it('removes a queued message the provider confirms Stop cancelled', () => { + const result = rejectedWith(DISPATCH_REJECTED_CANCELLED) + if (!result.ok) { + throw new Error('expected rejected submission fixture') + } + + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + }) + it('never puts the transport marker on screen', () => { const shown = notice(dispatchWriteFailureReason(new Error('broken pipe'))) // `provider_write_failed: broken pipe` names nothing a person can act on. From 742a7ad8424d9c45d717fad7064fa32835e4e3c2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:05:36 -0700 Subject: [PATCH 32/43] fix(omp): resume independent child sessions from history (#20629) Add Resume to eligible local OMP child history rows. Resolve lazy child targets from their own cwd and host, never an unrelated active workspace. Unresolved folder-only targets stay disabled; copy-command remains available. Verified production map/resume resolver regression before/after; 50 focused tests and independent 40-test review, web types and code quality passed. Actual OMP storage/CLI smoke confirms distinct child/grandchild sessions. No native Windows or live SSH launch claim. Addresses #12885 Scope 1. --- .../components/right-sidebar/AiVaultPanel.tsx | 7 +- .../right-sidebar/AiVaultSessionDetails.tsx | 5 +- .../right-sidebar/AiVaultSessionRow.test.tsx | 46 ++++++++++- .../right-sidebar/AiVaultSessionRow.tsx | 4 + .../AiVaultSessionSubagents.test.tsx | 61 +++++++++++++- .../right-sidebar/AiVaultSessionSubagents.tsx | 67 ++++++++++++++-- .../right-sidebar/AiVaultVirtualRow.tsx | 1 + .../right-sidebar/ai-vault-session-resume.ts | 27 +++++++ .../ai-vault-session-worktree-map.test.tsx | 55 +++++++++++++ .../omp-child-history-rendered/README.md | 13 +++ .../omp-child-history-rendered/fixture.css | 5 ++ .../omp-child-history-rendered/fixture.tsx | 80 +++++++++++++++++++ .../omp-child-history-rendered/index.html | 10 +++ .../tools/omp-child-history-rendered/run.mjs | 71 ++++++++++++++++ .../tools/omp-child-session-resume-smoke.mjs | 74 +++++++++++++++++ 15 files changed, 511 insertions(+), 15 deletions(-) create mode 100644 tests/tools/omp-child-history-rendered/README.md create mode 100644 tests/tools/omp-child-history-rendered/fixture.css create mode 100644 tests/tools/omp-child-history-rendered/fixture.tsx create mode 100644 tests/tools/omp-child-history-rendered/index.html create mode 100644 tests/tools/omp-child-history-rendered/run.mjs create mode 100644 tests/tools/omp-child-session-resume-smoke.mjs diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 0e0fe1d7a78..a24ce03b448 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -27,7 +27,7 @@ import { } from './ai-vault-session-projects' import { resolveAiVaultSessionResumeActions, - resolveAiVaultSessionResumeState + resolveAiVaultHistorySessionResumeState } from './ai-vault-session-resume' import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions' import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat' @@ -263,9 +263,8 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeState = useCallback( (session: AiVaultSession) => - resolveAiVaultSessionResumeState({ - sessionFilePath: session.filePath, - sessionExecutionHostId: session.executionHostId, + resolveAiVaultHistorySessionResumeState({ + session, worktreeInfo: getSessionWorktreeInfo(session), activeWorktreeId: effectiveActiveWorktreeId, worktrees: allWorktrees, diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index aa4f2e1430e..34fee190be6 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -1,3 +1,4 @@ +import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' import type React from 'react' import { FileJson, @@ -36,6 +37,7 @@ export function SessionInlineDetails({ resumeActions, onResumeInWorktree, onResumeInNewTab, + subagentResume, onContinueInNewSession, onResumeInNewChat, onOpenLog @@ -50,6 +52,7 @@ export function SessionInlineDetails({ } onResumeInWorktree: () => void onResumeInNewTab: () => void + subagentResume?: AiVaultSubagentResumeActions onContinueInNewSession?: () => void onResumeInNewChat?: () => void onOpenLog?: () => void @@ -217,7 +220,7 @@ export function SessionInlineDetails({ )} - + {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx index 82b128f1d49..ba14936aae0 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx @@ -1,11 +1,12 @@ // @vitest-environment happy-dom -import { cleanup, render, screen, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' import { VaultSessionRow } from './AiVaultSessionRow' const session = { @@ -58,6 +59,8 @@ afterEach(() => { function renderRow( overrides: { + session?: AiVaultSession + subagentResume?: AiVaultSubagentResumeActions detailsExpanded?: boolean worktreeInfo?: AiVaultSessionWorktreeInfo | null onToggleDetails?: () => void @@ -67,7 +70,8 @@ function renderRow( return render( { expect(container.querySelectorAll(`[title="${worktreeInfo.label}"]`)).toHaveLength(1) }) }) + +it('threads child resume through expanded parent details without resuming the parent', async () => { + const child: AiVaultSession = { + ...session, + agent: 'omp', + sessionId: 'child', + filePath: '/tmp/parent/worker.jsonl', + title: 'OMP worker', + subagent: { parentSessionId: 'parent', agentType: 'worker', status: 'completed' } + } + vi.mocked(window.api.aiVault.listSubagentSessions).mockResolvedValue({ + sessions: [child], + issues: [] + }) + const resume = { + getState: vi.fn(() => ({ + blocked: false, + worktreeId: 'folder:target', + usesSessionWorktree: false + })), + onResume: vi.fn() + } + renderRow({ + session: { + ...session, + agent: 'omp', + sessionId: 'parent', + messageCount: 0, + previewMessages: [], + subagentTranscriptCount: 1 + }, + detailsExpanded: true, + subagentResume: resume + }) + await act(async () => {}) + fireEvent.click(screen.getByTitle('Resume in New Tab')) + expect(resume.onResume).toHaveBeenCalledExactlyOnceWith(child, 'folder:target') +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 03682d35681..2add6113108 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,3 +1,4 @@ +import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' import { useCallback } from 'react' import type React from 'react' import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' @@ -44,6 +45,7 @@ export function VaultSessionRow({ resumeActions, onResumeInWorktree, onResumeInNewTab, + subagentResume, onCopyResume, onCopyId, onCopyPath, @@ -71,6 +73,7 @@ export function VaultSessionRow({ resumeActions: AiVaultSessionResumeActions onResumeInWorktree: () => void onResumeInNewTab: () => void + subagentResume?: AiVaultSubagentResumeActions onCopyResume?: () => void onCopyId: () => void onCopyPath: () => void @@ -219,6 +222,7 @@ export function VaultSessionRow({ resumeActions={resumeActions} onResumeInWorktree={onResumeInWorktree} onResumeInNewTab={onResumeInNewTab} + subagentResume={subagentResume} onContinueInNewSession={onContinueInNewSession} onResumeInNewChat={onResumeInNewChat} onOpenLog={onOpenLog} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx index 24bc826e973..973f86cfa03 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import type { ComponentProps, JSX } from 'react' -import { act, render } from '@testing-library/react' +import { act, fireEvent, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { AiVaultSession, AiVaultSubagentListResult } from '../../../../shared/ai-vault-types' @@ -125,3 +125,62 @@ describe('SessionSubagentsSection', () => { expect(container.firstChild).toBeNull() }) }) + +describe('independent child resume', () => { + const child = makeSession({ + agent: 'omp', + sessionId: 'child-id', + filePath: '/repo/session/tasks/worker.jsonl', + subagent: { parentSessionId: 'parent-session', agentType: 'worker', status: 'completed' }, + subagentTranscriptCount: 0 + }) + it('passes the complete child and its resolved folder target to resume', async () => { + listSubagentSessions.mockResolvedValue({ sessions: [child], issues: [] }) + const resume = { + getState: vi.fn(() => ({ + blocked: false, + worktreeId: 'folder:repo', + usesSessionWorktree: true + })), + onResume: vi.fn() + } + const { getByRole } = render( + + ) + await act(async () => {}) + fireEvent.click(getByRole('button', { name: 'Resume in Worktree' })) + expect(resume.getState).toHaveBeenCalledWith(child) + expect(resume.onResume).toHaveBeenCalledExactlyOnceWith(child, 'folder:repo') + }) + it.each([ + { agent: 'claude' as const }, + { sessionId: 'parent-session' }, + { sessionId: '' }, + { filePath: '' }, + { messageCount: 0, previewMessages: [] } + ])('withholds resume for a non-independent or empty child %j', async (overrides) => { + listSubagentSessions.mockResolvedValue({ sessions: [{ ...child, ...overrides }], issues: [] }) + const resume = { getState: vi.fn(), onResume: vi.fn() } + const { queryByRole } = render( + + ) + await act(async () => {}) + expect(queryByRole('button', { name: /Resume/ })).toBeNull() + expect(resume.getState).not.toHaveBeenCalled() + }) + it('disables resume when the existing target resolver blocks the child host', async () => { + listSubagentSessions.mockResolvedValue({ sessions: [child], issues: [] }) + const resume = { + getState: vi.fn(() => ({ blocked: true, worktreeId: null, usesSessionWorktree: false })), + onResume: vi.fn() + } + const { getByRole } = render( + + ) + await act(async () => {}) + const button = getByRole('button', { name: 'Resume in New Tab' }) + expect(button.hasAttribute('disabled')).toBe(true) + fireEvent.click(button) + expect(resume.onResume).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx index fbea6e9edfe..f80953ac228 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx @@ -1,26 +1,51 @@ import { useEffect, useState } from 'react' import type React from 'react' -import { Bot, FileJson } from 'lucide-react' +import { Bot, FileJson, Play } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { AgentStateDot, type AgentDotState } from '@/components/AgentStateDot' -import type { AiVaultSession, AiVaultSubagentRunStatus } from '../../../../shared/ai-vault-types' +import { + isAiVaultSessionResumableContent, + type AiVaultSession, + type AiVaultSubagentRunStatus +} from '../../../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { canOpenAiVaultSessionLogInOrca } from './ai-vault-session-path-actions' import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open' import { translate } from '@/i18n/i18n' +import { + aiVaultSessionResumeLabel, + type AiVaultSessionResumeState +} from './ai-vault-session-resume' + +export type AiVaultSubagentResumeActions = { + getState: (session: AiVaultSession) => AiVaultSessionResumeState + onResume: (session: AiVaultSession, worktreeId: string) => void +} + +function isIndependentlyResumableSubagent(session: AiVaultSession): boolean { + return ( + session.agent === 'omp' && + Boolean(session.subagent) && + Boolean(session.sessionId.trim()) && + session.sessionId !== session.subagent?.parentSessionId && + Boolean(session.filePath.trim()) && + isAiVaultSessionResumableContent(session) + ) +} type SubagentListState = { status: 'loading' } | { status: 'loaded'; sessions: AiVaultSession[] } /** * Lists the Task subagent transcripts spawned by one session, fetched on - * demand when the parent's details expand. Subagents share the parent's - * sessionId and aren't independently resumable, so rows are view-only. + * demand when the parent's details expand. OMP children own resumable sessions. */ export function SessionSubagentsSection({ - session + session, + resume }: { session: AiVaultSession + resume?: AiVaultSubagentResumeActions }): React.JSX.Element | null { const subagents = useSubagentSessions(session) @@ -44,7 +69,7 @@ export function SessionSubagentsSection({
{subagents.sessions.map((subagentSession) => ( - + ))}
@@ -112,7 +137,15 @@ const SUBAGENT_DOT_STATES: Record = { stopped: 'interrupted' } -function SubagentSessionLine({ session }: { session: AiVaultSession }): React.JSX.Element { +function SubagentSessionLine({ + session, + resume +}: { + session: AiVaultSession + resume?: AiVaultSubagentResumeActions +}): React.JSX.Element { + const resumeState = + resume && isIndependentlyResumableSubagent(session) ? resume.getState(session) : null const dotState = session.subagent?.status ? SUBAGENT_DOT_STATES[session.subagent.status] : null return ( @@ -145,6 +178,26 @@ function SubagentSessionLine({ session }: { session: AiVaultSession }): React.JS { value0: session.messageCount } )} + {resumeState ? ( + + ) : null} {canOpenAiVaultSessionLogInOrca(session) ? ( +
+ ) : null} + {subagents.status === 'loaded' && subagents.sessions.length === 0 ? ( +

+ {translate('aiVault.subagents.empty', 'No subagents found.')} +

+ ) : null}
- {subagents.sessions.map((subagentSession) => ( - + {subagents.sessions.map((child) => ( + ))}
) } -function useSubagentSessions(session: AiVaultSession): SubagentListState { - const [state, setState] = useState({ status: 'loading' }) - - useEffect(() => { - // The scan already counted the transcripts; skip the IPC round-trip when - // there is nothing to list. Remote sessions can carry a count (from the - // remote walk listing), but their transcripts aren't local files to list. - if ( - session.subagentTranscriptCount === 0 || - session.executionHostId !== LOCAL_EXECUTION_HOST_ID - ) { - setState({ status: 'loaded', sessions: [] }) - return - } - let cancelled = false - // Why: rescans re-run this effect (modifiedAt changes); resetting to - // loading would unmount the section until IPC returns and flicker on - // every active-session rescan. Keep prior rows visible while refetching. - setState((prev) => (prev.status === 'loaded' ? prev : { status: 'loading' })) - window.api.aiVault - .listSubagentSessions({ - agent: session.agent, - parentFilePath: session.filePath, - executionHostId: session.executionHostId - }) - .then((result) => { - if (!cancelled) { - setState({ status: 'loaded', sessions: result.sessions }) +function SubagentBranchRow({ + session, + resume, + ancestors +}: SectionProps & { ancestors: string[] }): React.JSX.Element { + const expansion = useSubagentExpansion() + const contentId = useId() + const locator = subagentTranscriptKey(session) + const key = JSON.stringify([...ancestors, locator]) + const expandable = + session.agent === 'omp' && + session.executionHostId === LOCAL_EXECUTION_HOST_ID && + session.subagentTranscriptCount > 0 && + !ancestors.includes(locator) + const open = expandable && Boolean(expansion?.expanded.has(key)) + const label = translate( + 'auto.components.right.sidebar.AiVaultSessionSubagents.subagentsCount', + 'Subagents ({{value0}})', + { value0: session.subagentTranscriptCount } + ) + return ( + expansion?.setExpanded(key, value)}> + + + + + + + {label} + + ) : null } - }) - .catch(() => { - // A failed listing degrades to "no subagents" — the section stays hidden. - if (!cancelled) { - setState({ status: 'loaded', sessions: [] }) - } - }) - return () => { - cancelled = true - } - // Why: modifiedAt changes exactly when the parent transcript is rewritten, - // so re-listing on it refreshes a subagent's status (e.g. running -> done). - }, [ - session.agent, - session.filePath, - session.executionHostId, - session.subagentTranscriptCount, - session.modifiedAt - ]) - - return state + /> + + {open ? ( + + ) : null} + + + ) } // AI Vault run statuses map onto the shared dot vocabulary: a completed Task @@ -139,84 +189,91 @@ const SUBAGENT_DOT_STATES: Record = { function SubagentSessionLine({ session, - resume + resume, + disclosure }: { session: AiVaultSession resume?: AiVaultSubagentResumeActions + disclosure?: React.ReactNode }): React.JSX.Element { const resumeState = resume && isIndependentlyResumableSubagent(session) ? resume.getState(session) : null const dotState = session.subagent?.status ? SUBAGENT_DOT_STATES[session.subagent.status] : null return ( -
- {dotState ? ( - // Why: a plain inline span would baseline-align the dot; flex keeps it - // vertically centered with the row text. - - +
+
+ {disclosure} + {dotState ? ( + // Why: a plain inline span would baseline-align the dot; flex keeps it + // vertically centered with the row text. + + + + ) : null} + + {session.title} - ) : null} - - {session.title} - - {session.subagent?.agentType ? ( - - {session.subagent.agentType} - - ) : null} - - {translate( - 'auto.components.right.sidebar.AiVaultSessionSubagents.messageCount', - '{{value0}} msgs', - { value0: session.messageCount } - )} - - {resumeState ? ( - - ) : null} - {canOpenAiVaultSessionLogInOrca(session) ? ( - + ) : null} + {canOpenAiVaultSessionLogInOrca(session) ? ( + + ) : null} +
+
+ {session.subagent?.agentType ? ( + + {session.subagent.agentType} + + ) : null} + + {translate( + 'auto.components.right.sidebar.AiVaultSessionSubagents.messageCount', + '{{value0}} msgs', + { value0: session.messageCount } )} - onClick={(event) => { - event.stopPropagation() - void openAiVaultSessionLogInOrca(session) - }} - className="shrink-0 text-muted-foreground" - > - - - ) : null} + +
) } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 1d52caf8c48..0e50e9ba89b 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -1,3 +1,4 @@ +import { SubagentExpansionProvider } from './ai-vault-subagent-expansion' import { useVirtualizer } from '@tanstack/react-virtual' import { useCallback, useMemo, useRef, useState } from 'react' import type { AgentStatusState } from '../../../../shared/agent-status-types' @@ -157,75 +158,77 @@ export function AiVaultSessionVirtualList({ }) return ( -
- {loading && sessionsCount === 0 ? : null} + +
+ {loading && sessionsCount === 0 ? : null} - {!loading && sessionsCount === 0 && !error ? ( - - ) : null} + {!loading && sessionsCount === 0 && !error ? ( + + ) : null} - {sessionsCount > 0 && filteredSessionsCount === 0 ? ( - - ) : null} + {sessionsCount > 0 && filteredSessionsCount === 0 ? ( + + ) : null} - {vaultRows.length > 0 ? ( -
- {virtualItems.map((virtualRow) => ( - - ))} -
- ) : null} -
+ {vaultRows.length > 0 ? ( +
+ {virtualItems.map((virtualRow) => ( + + ))} +
+ ) : null} +
+ ) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-subagent-expansion.tsx b/src/renderer/src/components/right-sidebar/ai-vault-subagent-expansion.tsx new file mode 100644 index 00000000000..d2c4a7e9b2b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-subagent-expansion.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useMemo, useState, type ReactNode } from 'react' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' + +type Expansion = { + expanded: ReadonlySet + setExpanded: (key: string, open: boolean) => void +} +const SubagentExpansionContext = createContext(null) + +export function subagentTranscriptKey(session: AiVaultSession): string { + return JSON.stringify([ + session.executionHostId, + session.agent, + normalizeRuntimePathForComparison(session.filePath) + ]) +} + +export function SubagentExpansionProvider({ + children +}: { + children: ReactNode +}): React.JSX.Element { + const [expanded, setExpanded] = useState>(() => new Set()) + const value = useMemo( + () => ({ + expanded, + setExpanded: (key, open) => + setExpanded((current) => { + const next = new Set(current) + if (open) { + next.add(key) + } else { + next.delete(key) + } + return next + }) + }), + [expanded] + ) + return ( + {children} + ) +} + +export function useSubagentExpansion(): Expansion | null { + return useContext(SubagentExpansionContext) +} diff --git a/src/renderer/src/components/right-sidebar/use-subagent-sessions.ts b/src/renderer/src/components/right-sidebar/use-subagent-sessions.ts new file mode 100644 index 00000000000..b86df136709 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-subagent-sessions.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' + +type SubagentListState = { + sessions: AiVaultSession[] + status: 'loading' | 'loaded' | 'error' +} + +// The caller keys the branch by transcript identity; rescans retain its loaded rows. +export function useSubagentSessions( + session: AiVaultSession +): SubagentListState & { retry: () => void; showLoading: boolean } { + const [state, setState] = useState({ status: 'loading', sessions: [] }) + const [showLoading, setShowLoading] = useState(false) + const [attempt, setAttempt] = useState(0) + useEffect(() => { + let cancelled = false + setShowLoading(false) + const loadingTimer = setTimeout(() => setShowLoading(true), 200) + setState((previous) => ({ ...previous, status: 'loading' })) + window.api.aiVault + .listSubagentSessions({ + agent: session.agent, + parentFilePath: session.filePath, + executionHostId: session.executionHostId + }) + .then((result) => { + clearTimeout(loadingTimer) + if (!cancelled) { + setState({ + status: result.issues.some((issue) => issue.kind !== 'notice') ? 'error' : 'loaded', + sessions: result.sessions + }) + } + }) + .catch(() => { + clearTimeout(loadingTimer) + if (!cancelled) { + setState((previous) => ({ ...previous, status: 'error' })) + } + }) + return () => { + cancelled = true + clearTimeout(loadingTimer) + } + }, [ + session.agent, + session.filePath, + session.executionHostId, + session.subagentTranscriptCount, + session.modifiedAt, + attempt + ]) + return { + ...state, + showLoading: showLoading && state.status === 'loading', + retry: () => setAttempt((value) => value + 1) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e2758ee2cc5..696e7f5ed7f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17881,5 +17881,15 @@ "stopped": "stopped", "finished": "finished" } + }, + "aiVault": { + "subagents": { + "loading": "Loading subagents…", + "loadError": "Could not load all subagents.", + "empty": "No subagents found." + } + }, + "common": { + "retry": "Retry" } } diff --git a/tests/tools/omp-child-history-rendered/README.md b/tests/tools/omp-child-history-rendered/README.md index 85c551031cb..52cd99a8371 100644 --- a/tests/tools/omp-child-history-rendered/README.md +++ b/tests/tools/omp-child-history-rendered/README.md @@ -1,13 +1,15 @@ # Child history resume proof Run `ORCA_BACKGROUND_LAUNCH=1 node tests/tools/omp-child-history-rendered/run.mjs`. -The hidden Electron fixture renders production subagent rows and styles with injected -OMP, Claude and empty OMP transcripts. It verifies only the independently resumable -OMP child offers resume and that clicking forwards that child's identity and folder -target. CDP screenshots and native visibility assertions are saved in `.bench-fixtures`. -This exercises the affordance and callback, not the complete terminal launch UI. +The hidden Electron fixture renders the production virtual history list and styles with +injected OMP, Claude and empty OMP transcripts. It opens eight generations lazily, +resumes the deepest child into a folder target, and checks that Claude remains view-only. +It verifies parent-row measurement avoids overlap, indentation stops growing, scrolling +away/back restores expansion, and collapse removes descendants. CDP screenshots and +native hidden/unfocused window assertions are saved in `.bench-fixtures`. +This exercises production rendering and callbacks, not the complete terminal launch UI. Run `ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-child-session-resume-smoke.mjs /path/to/oh-my-pi` for a zero-model-call check against real OMP session storage and CLI parsing. The smoke -builds Orca's path-based resume command, creates parent and nested child transcripts, -and verifies OMP selects the child's distinct identity in a folder workspace. +builds Orca's path-based resume command, creates parent, child and grandchild transcripts, +and verifies OMP selects each descendant's distinct identity in a folder workspace. diff --git a/tests/tools/omp-child-history-rendered/fixture.css b/tests/tools/omp-child-history-rendered/fixture.css index 6b6a3d4b1e9..30978a0b8da 100644 --- a/tests/tools/omp-child-history-rendered/fixture.css +++ b/tests/tools/omp-child-history-rendered/fixture.css @@ -1,5 +1,5 @@ @import '../../../src/renderer/src/assets/main.css'; @source './fixture.tsx'; -@source '../../../src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.tsx'; +@source '../../../src/renderer/src/components/right-sidebar'; @source '../../../src/renderer/src/components/ui'; @source '../../../src/renderer/src/components/AgentStateDot.tsx'; diff --git a/tests/tools/omp-child-history-rendered/fixture.tsx b/tests/tools/omp-child-history-rendered/fixture.tsx index 7dc138dc619..b8fb1b54707 100644 --- a/tests/tools/omp-child-history-rendered/fixture.tsx +++ b/tests/tools/omp-child-history-rendered/fixture.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import { createRoot } from 'react-dom/client' import { TooltipProvider } from '../../../src/renderer/src/components/ui/tooltip' -import { SessionSubagentsSection } from '../../../src/renderer/src/components/right-sidebar/AiVaultSessionSubagents' +import { AiVaultSessionVirtualList } from '../../../src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList' import type { AiVaultSession } from '../../../src/shared/ai-vault-types' import './fixture.css' @@ -34,12 +34,15 @@ const children: AiVaultSession[] = [ sessionId: 'child', title: 'OMP worker with saved conversation', filePath: '/sessions/parent/child.jsonl', + subagentTranscriptCount: 1, subagent: { parentSessionId: 'parent', agentType: 'worker', status: 'completed' } }, { ...parent, id: 'claude', agent: 'claude', + filePath: '/sessions/parent/claude.jsonl', + subagentTranscriptCount: 0, title: 'Claude worker (view only)', subagent: { parentSessionId: 'parent', agentType: 'worker', status: 'completed' } }, @@ -47,31 +50,104 @@ const children: AiVaultSession[] = [ ...parent, id: 'empty', sessionId: 'empty', + filePath: '/sessions/parent/empty.jsonl', + subagentTranscriptCount: 0, messageCount: 0, title: 'OMP worker without saved turns', subagent: { parentSessionId: 'parent', agentType: 'worker', status: 'stopped' } } ] + +const descendants = Array.from({ length: 7 }, (_, index): AiVaultSession => ({ + ...parent, + id: `depth-${index}`, + sessionId: `depth-${index}`, + filePath: `/sessions/parent/child/${Array.from({ length: index + 1 }, () => 'nested').join('/')}.jsonl`, + title: `Research depth ${index + 2}`, + subagentTranscriptCount: index === 6 ? 0 : 1, + subagent: { parentSessionId: 'child', agentType: 'researcher', status: 'completed' } +})) +const requests: string[] = [] +Object.defineProperty(window, 'nestedRequests', { value: requests }) Object.defineProperty(window, 'api', { - value: { aiVault: { listSubagentSessions: async () => ({ sessions: children, issues: [] }) } } + value: { + aiVault: { + listSubagentSessions: async ({ parentFilePath }: { parentFilePath: string }) => { + requests.push(parentFilePath) + await new Promise((resolve) => setTimeout(resolve, 250)) + const index = descendants.findIndex((session) => session.filePath === parentFilePath) + return { + sessions: + parentFilePath === parent.filePath + ? children + : parentFilePath === children[0].filePath + ? [descendants[0]] + : index !== -1 + ? descendants.slice(index + 1, index + 2) + : [], + issues: [] + } + } + } + } }) +const sessions = [ + parent, + ...Array.from({ length: 100 }, (_, index) => ({ + ...parent, + id: `row-${index}`, + sessionId: `row-${index}`, + title: `Other session ${index}`, + filePath: `/sessions/other-${index}.jsonl`, + subagentTranscriptCount: 0 + })) +] +const ignore = () => {} function App() { const [result, setResult] = useState('No resume requested') return (

Agent Session History

- ({ +
+ ({ command: session.resumeCommand })} + getOriginalPaneTarget={() => null} + getSessionLiveState={() => null} + getWorktreeInfo={() => null} + getSessionResumeState={() => ({ blocked: false, worktreeId: 'folder:project', usesSessionWorktree: true - }), - onResume: (session, target) => setResult(`Resume ${session.sessionId} in ${target}`) - }} - /> + })} + getSessionResumeActions={() => ({ + worktree: { worktreeId: 'folder:project', disabled: false }, + newTab: { worktreeId: 'folder:project', disabled: false } + })} + getSessionResumeInChat={() => ({ available: false, reason: 'agent' })} + onToggleGroup={ignore} + onJumpToOriginalPane={ignore} + onJumpToWorktree={ignore} + onResume={(session, target) => setResult(`Resume ${session.sessionId} in ${target}`)} + onContinueInNewSession={ignore} + onResumeInNewChat={ignore} + onCopyResume={ignore} + onCopyId={ignore} + onCopyPath={ignore} + onOpenLog={ignore} + onRevealLog={ignore} + onOpenCwd={ignore} + onRequestDelete={ignore} + /> +
{result}
diff --git a/tests/tools/omp-child-history-rendered/run.mjs b/tests/tools/omp-child-history-rendered/run.mjs index fcd137f982b..67866ffc985 100644 --- a/tests/tools/omp-child-history-rendered/run.mjs +++ b/tests/tools/omp-child-history-rendered/run.mjs @@ -35,7 +35,7 @@ const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env const app = await electron.launch({ args: [main], env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' } }) const report = { scope: - 'Production subagent rows with injected history records; callback evidence, not full launch UI.' + 'Production virtual history list and nested rows with injected records; hidden Electron/CDP layout and action targeting, not full launch UI.' } try { const page = await app.firstWindow() @@ -45,17 +45,140 @@ try { console.error(error) }) await page.goto(pathToFileURL(path.join(output, 'renderer/index.html')).href) + await page.getByTestId('ai-vault-session-toggle-details').first().click() await expect(page.getByText('OMP worker with saved conversation')).toBeVisible() - await expect(page.getByRole('button', { name: /Resume/ })).toHaveCount(1) + expect(await page.evaluate(() => window.nestedRequests.length)).toBe(1) const cdp = await page.context().newCDPSession(page) const capture = async (name) => { + await page.evaluate(async () => { + await Promise.all( + document + .getAnimations() + .filter((animation) => animation.effect?.getComputedTiming().iterations !== Infinity) + .map((animation) => animation.finished.catch(() => {})) + ) + }) const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }) writeFileSync(path.join(output, `${name}.png`), Buffer.from(data, 'base64')) } await capture('child-resume-affordance') - await page.getByRole('button', { name: 'Resume in Worktree' }).click() + await page + .getByText('OMP worker with saved conversation') + .locator('..') + .getByRole('button', { name: 'Resume in Worktree' }) + .click() await expect(page.getByText('Resume child in folder:project')).toBeVisible() await capture('child-resume-callback') + for (let depth = 2; depth <= 8; depth++) { + const row = + depth === 2 + ? page.getByText('OMP worker with saved conversation') + : page.getByText(`Research depth ${depth - 1}`, { exact: true }) + await row.locator('..').getByRole('button', { name: 'Subagents (1)' }).click() + await expect(page.getByText(`Research depth ${depth}`, { exact: true })).toBeVisible() + if (depth === 2) { + await capture('grandchild-disclosure-dark') + await page.evaluate(() => document.documentElement.classList.remove('dark')) + await capture('grandchild-disclosure-light') + await page.evaluate(() => document.documentElement.classList.add('dark')) + } + } + await page + .getByText('Research depth 8', { exact: true }) + .locator('..') + .getByRole('button', { name: 'Resume in Worktree' }) + .click() + await expect(page.getByText('Resume depth-6 in folder:project')).toBeVisible() + const scroll = page.locator('.overflow-y-auto').first() + const checkLayout = async () => { + const layout = await page.locator('[data-index="1"]').evaluate((element) => { + const next = document.querySelector('[data-index="2"]') + return { + height: element.getBoundingClientRect().height, + bottom: element.getBoundingClientRect().bottom, + nextTop: next?.getBoundingClientRect().top + } + }) + expect(layout.nextTop).toBeGreaterThanOrEqual(layout.bottom - 1) + return layout + } + await expect + .poll(async () => { + const bounds = await page.locator('[data-index="1"]').boundingBox() + const next = await page.locator('[data-index="2"]').boundingBox() + return next.y - bounds.y - bounds.height + }) + .toBeGreaterThanOrEqual(-1) + report.expandedLayout = await checkLayout() + const lefts = await page + .getByText(/^Research depth /) + .evaluateAll((elements) => + elements.map((element) => element.parentElement.getBoundingClientRect().left) + ) + expect(lefts.at(-1)).toBe(lefts.at(-2)) + report.depthLefts = lefts + await capture('nested-expanded') + report.sidebarWidths = [] + for (const width of [280, 350]) { + await page.getByTestId('history-panel').evaluate((element, value) => { + element.style.width = `${value}px` + }, width) + const measurements = await page.getByText(/^Research depth /).evaluateAll((elements) => + elements.map((element) => { + const row = element.parentElement + const bounds = row.getBoundingClientRect() + return { + titleWidth: element.getBoundingClientRect().width, + rowRight: bounds.right, + buttonsRight: Math.max( + ...[...row.querySelectorAll('button')].map( + (button) => button.getBoundingClientRect().right + ) + ) + } + }) + ) + expect( + measurements.every((row) => row.titleWidth >= 40 && row.buttonsRight <= row.rowRight + 1) + ).toBe(true) + report.sidebarWidths.push({ width, measurements }) + await page.getByText('Research depth 4', { exact: true }).scrollIntoViewIfNeeded() + await capture(`nested-width-${width}`) + } + await page.getByTestId('history-panel').evaluate((element) => { + element.style.width = '' + }) + + await scroll.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + await expect(page.getByText('OMP worker with saved conversation')).toHaveCount(0) + await scroll.evaluate((element) => { + element.scrollTop = 0 + }) + await expect(page.getByText('Research depth 8', { exact: true })).toBeVisible() + await page + .getByText('OMP worker with saved conversation') + .locator('..') + .getByRole('button', { name: 'Subagents (1)' }) + .click() + await expect(page.getByText('Research depth 8', { exact: true })).toHaveCount(0) + await expect + .poll(async () => { + const bounds = await page.locator('[data-index="1"]').boundingBox() + return bounds.height + }) + .toBeLessThan(report.expandedLayout.height) + await expect + .poll(async () => { + const bounds = await page.locator('[data-index="1"]').boundingBox() + const next = await page.locator('[data-index="2"]').boundingBox() + return Math.abs(next.y - bounds.y - bounds.height) + }) + .toBeLessThanOrEqual(1) + report.collapsedLayout = await checkLayout() + report.requests = await page.evaluate(() => window.nestedRequests) + await capture('nested-collapsed') expect(errors).toEqual([]) report.windows = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().map((window) => ({ diff --git a/tests/tools/omp-child-session-resume-smoke.mjs b/tests/tools/omp-child-session-resume-smoke.mjs index d50e5485fa6..baa7c7d4f19 100644 --- a/tests/tools/omp-child-session-resume-smoke.mjs +++ b/tests/tools/omp-child-session-resume-smoke.mjs @@ -41,26 +41,34 @@ try { child.appendMessage({ role: 'user', content: 'child task', timestamp: Date.now() }) await child.ensureOnDisk() await child.flush() - const command = buildAiVaultResumeCommand({ - agent: 'omp', - sessionId: child.getSessionId(), - resumeFilePath: child.getSessionFile(), - cwd: null, - platform: process.platform, - shell: 'posix' - }) - const tokens = tokenizeStartupCommand(command, 'posix') - assert.ok(tokens.ok) - const args = parseArgs(tokens.tokens.slice(1)) - assert.equal(args.resume, child.getSessionFile()) - const settings = await Settings.init({ cwd }) - const resumed = await createSessionManager(args, cwd, settings) - managers.push(resumed) - assert.equal(resumed.getSessionId(), child.getSessionId()) - assert.notEqual(resumed.getSessionId(), parent.getSessionId()) + const grandchild = SessionManager.create(cwd, child.getSessionFile().replace(/\.jsonl$/, '')) + managers.push(grandchild) + grandchild.appendMessage({ role: 'user', content: 'grandchild research', timestamp: Date.now() }) + await grandchild.ensureOnDisk() + await grandchild.flush() + for (const target of [child, grandchild]) { + const command = buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: target.getSessionId(), + resumeFilePath: target.getSessionFile(), + cwd: null, + platform: process.platform, + shell: 'posix' + }) + const tokens = tokenizeStartupCommand(command, 'posix') + assert.ok(tokens.ok) + const args = parseArgs(tokens.tokens.slice(1)) + assert.equal(args.resume, target.getSessionFile()) + const settings = await Settings.init({ cwd }) + const resumed = await createSessionManager(args, cwd, settings) + managers.push(resumed) + assert.equal(resumed.getSessionId(), target.getSessionId()) + assert.notEqual(resumed.getSessionId(), parent.getSessionId()) + } console.log( JSON.stringify({ childPathResumed: true, + grandchildPathResumed: true, distinctFromParent: true, folderWorkspace: true, modelCalls: 0 From b8554f1c59a68c2b9770f125485aec6c5b97c8dc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:22:05 -0700 Subject: [PATCH 34/43] fix(composer): clarify failed attachment drops (#20704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(renderer): give the IPC error reader a clamped and an unclamped shape * fix(composer): name the attachments a drop could not add, in one toast * fix(composer, source-control): use one stable failure toast slot - Replace per-worktree toast IDs with single slot that replaces on each failure - Remove destructive retry actions; discard must confirm in dialog - Consolidate filesystem import types to shared location - Add compactIpcErrorMessage for string error handling * refactor: centralize filesystem import types and clarify failure naming Move import result types from main/ipc to shared layer so they're available across preload and renderer. Rename uniformFailure → commonFailure and skippedOrFailed → failureCount for clarity. Simplify preload/API type definitions by reusing shared types directly instead of duplicating inlined union shapes. * Reuse single toast slot for composer drop failures Multiple drop failures now replace the previous toast instead of stacking, preventing notification clutter. Uses a dedicated toast ID separate from Source Control's stage/discard notifications. --- src/main/ipc/filesystem-import-local.ts | 2 +- src/main/ipc/filesystem-import-ssh.ts | 2 +- src/main/ipc/filesystem-mutations.ts | 2 +- .../ipc/filesystem-runtime-upload-staging.ts | 2 +- .../omp-native-title-win32.meta.json | 6 +- src/preload/api/filesystem-api.ts | 43 +--- src/preload/api/fs-bridge.ts | 44 +--- .../hooks/composer-drop-failure-toast.test.ts | 91 ++++++++ .../src/hooks/composer-drop-failure-toast.ts | 57 +++++ .../src/hooks/composer-drop-result.test.ts | 35 +++ .../src/hooks/composer-drop-result.ts | 58 +++++ .../hooks/composer-drop-upload-result.test.ts | 31 --- .../src/hooks/composer-drop-upload-result.ts | 44 ---- .../attachment-drop-failure.test.tsx | 205 ++++++++++++++++++ .../composer-state/attachment-drop-state.ts | 64 ++++-- .../src/i18n/en-runtime-required.json | 8 + src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - src/renderer/src/lib/ipc-error.test.ts | 17 +- src/renderer/src/lib/ipc-error.ts | 19 +- .../src/runtime/runtime-file-import-client.ts | 24 +- .../filesystem-import-result-types.ts | 4 +- 26 files changed, 558 insertions(+), 215 deletions(-) create mode 100644 src/renderer/src/hooks/composer-drop-failure-toast.test.ts create mode 100644 src/renderer/src/hooks/composer-drop-failure-toast.ts create mode 100644 src/renderer/src/hooks/composer-drop-result.test.ts create mode 100644 src/renderer/src/hooks/composer-drop-result.ts delete mode 100644 src/renderer/src/hooks/composer-drop-upload-result.test.ts delete mode 100644 src/renderer/src/hooks/composer-drop-upload-result.ts create mode 100644 src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx rename src/{main/ipc => shared}/filesystem-import-result-types.ts (81%) diff --git a/src/main/ipc/filesystem-import-local.ts b/src/main/ipc/filesystem-import-local.ts index 49b910dac5f..1df2b3f2fb3 100644 --- a/src/main/ipc/filesystem-import-local.ts +++ b/src/main/ipc/filesystem-import-local.ts @@ -2,7 +2,7 @@ import { lstat, rm } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { authorizeExternalPath } from './filesystem-auth' import { isENOENT } from './filesystem-path-containment' -import type { ImportItemResult } from './filesystem-import-result-types' +import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { copyLocalFileNoFollow, preScanForSymlinks, diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index ae17f3d8e19..9268c6ece24 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -5,7 +5,7 @@ import { isENOENT } from './filesystem-path-containment' import { getSshConnectionManager } from './ssh' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type { ImportItemResult } from './filesystem-import-result-types' +import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 7cce83aa3e5..ad1308f0c30 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -16,7 +16,7 @@ import type { ImportSkipReason, ResolveDroppedPathsResult, StagedExternalImportSource -} from './filesystem-import-result-types' +} from '../../shared/filesystem-import-result-types' import { importOneSource } from './filesystem-import-local' import { stagedRuntimeUploadByteLength, diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index 86b5f884820..6531f8a499a 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -11,7 +11,7 @@ import { isENOENT } from './filesystem-path-containment' import type { StagedExternalImportEntry, StagedExternalImportSource -} from './filesystem-import-result-types' +} from '../../shared/filesystem-import-result-types' class RuntimeUploadSymlinkError extends Error {} diff --git a/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json index 8594baa7630..6f64734a888 100644 --- a/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json +++ b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json @@ -1,11 +1,7 @@ { "capturedAt": "2026-09-14T11:25:01.730Z", "platform": "darwin", - "command": [ - "bun", - "tests/tools/omp-native-title-capture.mjs", - "" - ], + "command": ["bun", "tests/tools/omp-native-title-capture.mjs", ""], "cols": 100, "rows": 30, "note": "OMP source ne7546987ca526eac8f605fac19ef9805b8f01898 buildTerminalTitleWithState; explicit win32 argument on macOS PTY, synthetic state transitions, no model/account. Not a Windows runtime capture.", diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 21acebad53a..acb49f500d3 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -5,6 +5,11 @@ import type { FsChangedPayload, MarkdownDocument } from '../../shared/filesystem-entry-types' +import type { + ImportItemResult, + ResolveDroppedPathsResult, + StagedExternalImportSource +} from '../../shared/filesystem-import-result-types' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -12,10 +17,7 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import type { SshMutationExpectation } from '../../shared/ssh-types' -import type { - RuntimeUploadFileStreamRequest, - StageRuntimeUploadResult -} from '../../shared/runtime-upload-staging-contract' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' export type ExportApi = { htmlToPdf: (args: { @@ -138,30 +140,10 @@ export type FilesystemApi = { connectionId?: string ensureDir?: boolean } & SshMutationExpectation - ) => Promise<{ - results: ( - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> + ) => Promise<{ results: ImportItemResult[] }> stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] - }) => Promise + }) => Promise<{ sources: StagedExternalImportSource[] }> uploadExternalFileToRuntime: ( args: RuntimeUploadFileStreamRequest ) => Promise<{ byteLength: number }> @@ -171,14 +153,7 @@ export type FilesystemApi = { worktreePath: string connectionId?: string } & SshMutationExpectation - ) => Promise<{ - resolvedPaths: string[] - skipped: { - sourcePath: string - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - }[] - failed: { sourcePath: string; reason: string }[] - }> + ) => Promise watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 05c6eaee477..2f702d9464f 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,12 +1,14 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' import { ipcRenderer } from 'electron' import type { SshMutationExpectation } from '../../shared/ssh-types' -import type { - RuntimeUploadFileStreamRequest, - StageRuntimeUploadResult -} from '../../shared/runtime-upload-staging-contract' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' import type { SearchResult } from '../../shared/code-search-types' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' +import type { + ImportItemResult, + ResolveDroppedPathsResult, + StagedExternalImportSource +} from '../../shared/filesystem-import-result-types' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -155,30 +157,10 @@ export const fsApi = { connectionId?: string ensureDir?: boolean } & SshMutationExpectation - ): Promise<{ - results: ( - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:importExternalPaths', args), + ): Promise<{ results: ImportItemResult[] }> => ipcRenderer.invoke('fs:importExternalPaths', args), stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] - }): Promise => + }): Promise<{ sources: StagedExternalImportSource[] }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), uploadExternalFileToRuntime: ( args: RuntimeUploadFileStreamRequest @@ -189,14 +171,8 @@ export const fsApi = { worktreePath: string connectionId?: string } & SshMutationExpectation - ): Promise<{ - resolvedPaths: string[] - skipped: { - sourcePath: string - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - }[] - failed: { sourcePath: string; reason: string }[] - }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), + ): Promise => + ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:watchWorktree', args), unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => diff --git a/src/renderer/src/hooks/composer-drop-failure-toast.test.ts b/src/renderer/src/hooks/composer-drop-failure-toast.test.ts new file mode 100644 index 00000000000..ab2945065cb --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-failure-toast.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { toastError } = vi.hoisted(() => ({ + toastError: vi.fn<(title: string, options?: { id?: string; description?: string }) => void>() +})) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +import { showComposerDropFailureToast } from './composer-drop-failure-toast' +import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types' + +const SKIP_REASON_COPY = [ + ['missing', 'No longer at its original path.'], + ['symlink', 'Symbolic links cannot be attached.'], + ['permission-denied', 'Permission denied.'], + ['unsupported', 'Unsupported file type.'] +] as const satisfies readonly (readonly [ImportSkipReason, string])[] + +function lastToast(): { title: string; id?: string; description?: string } { + const call = toastError.mock.calls.at(-1) + return { + title: String(call?.[0]), + id: call?.[1]?.id, + description: call?.[1]?.description + } +} + +describe('showComposerDropFailureToast', () => { + beforeEach(() => { + toastError.mockClear() + }) + + it('stays neutral about the gesture, and pluralises like its namespace siblings', () => { + showComposerDropFailureToast({ failureCount: 1, total: 1 }) + expect(lastToast().title).toBe('1 of 1 item could not be attached.') + + showComposerDropFailureToast({ failureCount: 2, total: 5 }) + expect(lastToast().title).toBe('2 of 5 items could not be attached.') + }) + + it("turns the import client's skip enum into copy instead of leaking the token", () => { + for (const [reason, expected] of SKIP_REASON_COPY) { + showComposerDropFailureToast({ + failureCount: 1, + total: 3, + commonFailure: { status: 'skipped', reason } + }) + expect(lastToast().description).toBe(expected) + } + }) + + it('passes a free-form failure reason straight through', () => { + showComposerDropFailureToast({ + failureCount: 2, + total: 4, + commonFailure: { status: 'failed', reason: 'EACCES: permission denied' } + }) + expect(lastToast().description).toBe('EACCES: permission denied') + }) + + it('shows no description when nothing explained the failure', () => { + showComposerDropFailureToast({ failureCount: 1, total: 2 }) + expect(lastToast().description).toBeUndefined() + }) + + it('unwraps and clamps a host-minted failure reason before it reaches the row', () => { + showComposerDropFailureToast({ + failureCount: 1, + total: 2, + commonFailure: { + status: 'failed', + reason: + "Error invoking remote method 'runtime:call': Error: EACCES: permission denied\nat Object.upload" + } + }) + expect(lastToast().description).toBe('EACCES: permission denied') + }) + + it('reuses one slot so a second failed drop replaces the first instead of stacking', () => { + showComposerDropFailureToast({ failureCount: 1, total: 2 }) + const first = lastToast().id + showComposerDropFailureToast({ failureCount: 2, total: 3 }) + expect(first).toBeDefined() + expect(lastToast().id).toBe(first) + }) + + it('gives no reason at all when the batch failed for differing reasons', () => { + showComposerDropFailureToast({ failureCount: 3, total: 6 }) + expect(lastToast().title).toBe('3 of 6 items could not be attached.') + expect(lastToast().description).toBeUndefined() + }) +}) diff --git a/src/renderer/src/hooks/composer-drop-failure-toast.ts b/src/renderer/src/hooks/composer-drop-failure-toast.ts new file mode 100644 index 00000000000..a3dde2b7493 --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-failure-toast.ts @@ -0,0 +1,57 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { compactIpcErrorMessage } from '@/lib/ipc-error' +import type { ComposerDropFailure } from './composer-drop-result' +import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types' + +// Own slot, not Source Control's: a drop failure must not erase an unread stage/discard failure. +const DROP_FAILURE_TOAST_ID = 'composer-drop-failure' + +const SKIP_REASON_COPY: Record = { + missing: { + key: 'auto.hooks.useComposerState.attachSkipMissing', + fallback: 'No longer at its original path.' + }, + symlink: { + key: 'auto.hooks.useComposerState.attachSkipSymlink', + fallback: 'Symbolic links cannot be attached.' + }, + 'permission-denied': { + key: 'auto.hooks.useComposerState.attachSkipPermissionDenied', + fallback: 'Permission denied.' + }, + unsupported: { + key: 'auto.hooks.useComposerState.attachSkipUnsupported', + fallback: 'Unsupported file type.' + } +} + +function failureDescription(failure: ComposerDropFailure): string | undefined { + if (failure.status === 'failed') { + return failure.reason ? compactIpcErrorMessage(failure.reason) : undefined + } + const copy = SKIP_REASON_COPY[failure.reason] + return copy ? translate(copy.key, copy.fallback) : undefined +} + +export function showComposerDropFailureToast({ + failureCount, + total, + commonFailure +}: { + failureCount: number + total: number + commonFailure?: ComposerDropFailure +}): void { + toast.error( + translate( + 'auto.hooks.useComposerState.dropPartiallyAttached', + '{{failureCount}} of {{count}} items could not be attached.', + { failureCount, count: total } + ), + { + id: DROP_FAILURE_TOAST_ID, + description: commonFailure ? failureDescription(commonFailure) : undefined + } + ) +} diff --git a/src/renderer/src/hooks/composer-drop-result.test.ts b/src/renderer/src/hooks/composer-drop-result.test.ts new file mode 100644 index 00000000000..7909c3b91e0 --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-result.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { collectComposerDropResult, type ComposerDropItemResult } from './composer-drop-result' + +describe('composer drop result', () => { + it('separates imported files and folders while summarizing failures', () => { + const results: ComposerDropItemResult[] = [ + { status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' }, + { status: 'imported', kind: 'directory', destPath: '/repo/.orca/drops/folder' }, + { status: 'skipped', reason: 'permission-denied' }, + { status: 'failed', reason: 'disk full' } + ] + + expect(collectComposerDropResult(results)).toEqual({ + filePaths: ['/repo/.orca/drops/file.txt'], + folderPaths: ['/repo/.orca/drops/folder'], + failureCount: 2, + commonFailure: undefined + }) + }) + + it('keeps a failure only when it explains the whole failed subset', () => { + expect( + collectComposerDropResult([ + { status: 'skipped', reason: 'missing' }, + { status: 'skipped', reason: 'missing' } + ]).commonFailure + ).toEqual({ status: 'skipped', reason: 'missing' }) + + expect( + collectComposerDropResult([ + { status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' } + ]).commonFailure + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/hooks/composer-drop-result.ts b/src/renderer/src/hooks/composer-drop-result.ts new file mode 100644 index 00000000000..dd7334b98db --- /dev/null +++ b/src/renderer/src/hooks/composer-drop-result.ts @@ -0,0 +1,58 @@ +import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types' + +export type ComposerDropItemResult = + | { + status: 'imported' + destPath: string + kind: 'file' | 'directory' + } + | { + status: 'skipped' + reason: ImportSkipReason + } + | { + status: 'failed' + reason?: string + } + +export type ComposerDropFailure = Exclude + +export type ComposerDropResult = { + filePaths: string[] + folderPaths: string[] + failureCount: number + commonFailure?: ComposerDropFailure +} + +function sameFailure(left: ComposerDropFailure, right: ComposerDropFailure): boolean { + return left.status === right.status && left.reason === right.reason +} + +export function collectComposerDropResult( + results: readonly ComposerDropItemResult[] +): ComposerDropResult { + const filePaths: string[] = [] + const folderPaths: string[] = [] + const failures: ComposerDropFailure[] = [] + + for (const result of results) { + if (result.status !== 'imported') { + failures.push(result) + } else if (result.kind === 'directory') { + folderPaths.push(result.destPath) + } else { + filePaths.push(result.destPath) + } + } + + const firstFailure = failures[0] + return { + filePaths, + folderPaths, + failureCount: failures.length, + commonFailure: + firstFailure && failures.every((failure) => sameFailure(firstFailure, failure)) + ? firstFailure + : undefined + } +} diff --git a/src/renderer/src/hooks/composer-drop-upload-result.test.ts b/src/renderer/src/hooks/composer-drop-upload-result.test.ts deleted file mode 100644 index fd7633cd9cf..00000000000 --- a/src/renderer/src/hooks/composer-drop-upload-result.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - collectComposerDropUploadResult, - shouldReportComposerDropUploadFailure, - type ComposerDropUploadImportResult -} from './composer-drop-upload-result' - -describe('composer drop upload result', () => { - it('separates imported files and folders while counting skipped or failed paths', () => { - const results: ComposerDropUploadImportResult[] = [ - { status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' }, - { status: 'imported', kind: 'directory', destPath: '/repo/.orca/drops/folder' }, - { status: 'skipped' }, - { status: 'failed' } - ] - - expect(collectComposerDropUploadResult(results)).toEqual({ - filePaths: ['/repo/.orca/drops/file.txt'], - folderPaths: ['/repo/.orca/drops/folder'], - skippedOrFailed: 2 - }) - }) - - it('suppresses failed-upload reporting after a composer loses drop ownership', () => { - const uploadResult = { skippedOrFailed: 1 } - - expect(shouldReportComposerDropUploadFailure(uploadResult, () => true)).toBe(true) - expect(shouldReportComposerDropUploadFailure(uploadResult, () => false)).toBe(false) - expect(shouldReportComposerDropUploadFailure({ skippedOrFailed: 0 }, () => true)).toBe(false) - }) -}) diff --git a/src/renderer/src/hooks/composer-drop-upload-result.ts b/src/renderer/src/hooks/composer-drop-upload-result.ts deleted file mode 100644 index a77cbbc9304..00000000000 --- a/src/renderer/src/hooks/composer-drop-upload-result.ts +++ /dev/null @@ -1,44 +0,0 @@ -export type ComposerDropUploadImportResult = - | { - status: 'imported' - destPath: string - kind: 'file' | 'directory' - } - | { - status: 'skipped' | 'failed' - } - -export type ComposerDropUploadResult = { - filePaths: string[] - folderPaths: string[] - skippedOrFailed: number -} - -export function collectComposerDropUploadResult( - results: readonly ComposerDropUploadImportResult[] -): ComposerDropUploadResult { - const filePaths: string[] = [] - const folderPaths: string[] = [] - let skippedOrFailed = 0 - - for (const result of results) { - if (result.status !== 'imported') { - skippedOrFailed += 1 - continue - } - if (result.kind === 'directory') { - folderPaths.push(result.destPath) - } else { - filePaths.push(result.destPath) - } - } - - return { filePaths, folderPaths, skippedOrFailed } -} - -export function shouldReportComposerDropUploadFailure( - uploadResult: Pick, - canReport: () => boolean -): boolean { - return uploadResult.skippedOrFailed > 0 && canReport() -} diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx new file mode 100644 index 00000000000..9b72a3d0dba --- /dev/null +++ b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { createRef } from 'react' +import type { Dispatch, SetStateAction } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ toastError: vi.fn(), importExternalPaths: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign(() => undefined, { getState: () => ({}) }) +})) +vi.mock('@/runtime/runtime-file-client', () => ({ + importExternalPathsToRuntime: (...args: unknown[]) => mocks.importExternalPaths(...args) +})) +vi.mock('./composer-drop-listener', () => ({ useComposerDropListener: vi.fn() })) + +import { useAttachmentDropState } from './attachment-drop-state' + +const FAILING_PATHS = new Set(['/drop/bad-1.png', '/drop/bad-2.png', '/drop/bad-3.png']) + +function dropPaths(count: number): string[] { + return [ + ...FAILING_PATHS, + ...Array.from({ length: count - FAILING_PATHS.size }, (_, index) => `/drop/ok-${index}.png`) + ] +} + +function installFsApi(): void { + Object.assign(window, { + api: { + fs: { + authorizeExternalPath: vi.fn(async () => {}), + stat: vi.fn(async ({ filePath }: { filePath: string }) => { + if (FAILING_PATHS.has(filePath)) { + throw new Error( + "Error invoking remote method 'fs:stat': Error: ENOENT: no such file or directory" + ) + } + return { isDirectory: false } + }) + } + } + }) +} + +function renderDropState(setAttachmentPaths: Dispatch>) { + return renderHook(() => + useAttachmentDropState({ + agentPromptRef: { current: '' }, + cancelPromptCaretFrame: () => {}, + connectionId: null, + promptCaretFrameRef: { current: null }, + promptTextareaRef: createRef(), + selectedRepoPath: '/repo', + selectedRepoSettings: null, + setAgentPrompt: () => {}, + setAttachmentPaths + }) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + installFsApi() +}) + +describe('local composer drop failures', () => { + it('reports partially skipped paths in one aggregated toast and still attaches the rest', async () => { + const attached: string[] = [] + const { result } = renderDropState((next) => { + attached.push(...(typeof next === 'function' ? next([]) : next)) + }) + + await act(async () => { + await result.current.applyLocalComposerDrop(dropPaths(12)) + }) + + expect(mocks.toastError).toHaveBeenCalledTimes(1) + const [title, options] = mocks.toastError.mock.calls[0] ?? [] + expect(title).toBe('3 of 12 items could not be attached.') + expect(options.description).toBe('No longer at its original path.') + expect(attached).toHaveLength(9) + expect(attached).not.toContain('/drop/bad-1.png') + }) + + it('stays silent when every dropped path attaches', async () => { + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.applyLocalComposerDrop(['/drop/ok-0.png', '/drop/ok-1.png']) + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('says nothing once the composer that owned the drop is gone', async () => { + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.applyLocalComposerDrop(dropPaths(12), () => false) + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) +}) + +// Why: the upload branch returns early unless a runtime environment or connection is resolved. +const RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: 'env-1' } + +describe('composer upload failures', () => { + it('aggregates a mixed runtime import into one toast, and withholds a reason that is not shared', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [ + { + sourcePath: '/a.png', + status: 'imported', + destPath: '/repo/.orca/drops/a.png', + kind: 'file', + renamed: false + }, + { sourcePath: '/b.png', status: 'skipped', reason: 'permission-denied' }, + { sourcePath: '/c.png', status: 'failed', reason: 'disk full' } + ] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths( + ['/a.png', '/b.png', '/c.png'], + RUNTIME_SETTINGS, + null, + '/repo' + ) + }) + + expect(mocks.toastError).toHaveBeenCalledTimes(1) + const [title, options] = mocks.toastError.mock.calls[0] ?? [] + expect(title).toBe('2 of 3 items could not be attached.') + expect(options.description).toBeUndefined() + }) + + it('stays silent when every uploaded path imports', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [ + { + sourcePath: '/a.png', + status: 'imported', + destPath: '/repo/.orca/drops/a.png', + kind: 'file', + renamed: false + } + ] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths(['/a.png'], RUNTIME_SETTINGS, null, '/repo') + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('does not report after the composer that owned the upload is gone', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [{ sourcePath: '/b.png', status: 'skipped', reason: 'missing' }] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths( + ['/b.png'], + RUNTIME_SETTINGS, + null, + '/repo', + () => false + ) + }) + + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('does give the shared reason when every uploaded path failed the same way', async () => { + mocks.importExternalPaths.mockResolvedValue({ + results: [ + { sourcePath: '/b.png', status: 'skipped', reason: 'permission-denied' }, + { sourcePath: '/c.png', status: 'skipped', reason: 'permission-denied' } + ] + }) + const { result } = renderDropState(() => {}) + + await act(async () => { + await result.current.uploadComposerPaths( + ['/b.png', '/c.png'], + RUNTIME_SETTINGS, + null, + '/repo' + ) + }) + + const [, options] = mocks.toastError.mock.calls[0] ?? [] + expect(options.description).toBe('Permission denied.') + }) +}) diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts index 6dcd27e8af5..ace0e0ba4f7 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts @@ -20,13 +20,27 @@ import { joinPath } from '@/lib/path' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { useAppStore } from '@/store' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { readIpcErrorMessage } from '@/lib/ipc-error' +import { showComposerDropFailureToast } from '../composer-drop-failure-toast' import { - collectComposerDropUploadResult, - shouldReportComposerDropUploadFailure -} from '../composer-drop-upload-result' + collectComposerDropResult, + type ComposerDropFailure, + type ComposerDropItemResult +} from '../composer-drop-result' import { applyComposerNativeFileDrop } from '../composer-native-file-drop' import { useComposerDropListener } from './composer-drop-listener' +// Local drops bypass the runtime importer's skip classification. +function localDropFailure(detail: string | undefined): ComposerDropFailure { + if (detail?.startsWith('ENOENT')) { + return { status: 'skipped', reason: 'missing' } + } + if (/^(EACCES|EPERM)/.test(detail ?? '')) { + return { status: 'skipped', reason: 'permission-denied' } + } + return { status: 'failed', reason: detail } +} + export function useAttachmentDropState(input: AttachmentDropStateInput) { const { agentPromptRef, @@ -164,14 +178,13 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { destinationDir, { ensureDestinationDir: true, assertCurrent } ) - const uploadResult = collectComposerDropUploadResult(results) - if (shouldReportComposerDropUploadFailure(uploadResult, canReportFailure)) { - toast.error( - translate( - 'auto.hooks.useComposerState.a9ff236145', - 'Some attachments could not be uploaded.' - ) - ) + const uploadResult = collectComposerDropResult(results) + if (uploadResult.failureCount > 0 && canReportFailure()) { + showComposerDropFailureToast({ + failureCount: uploadResult.failureCount, + total: sourcePaths.length, + commonFailure: uploadResult.commonFailure + }) } return { filePaths: uploadResult.filePaths, folderPaths: uploadResult.folderPaths } }, @@ -199,27 +212,34 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { const applyLocalComposerDrop = useCallback( async (paths: string[], canApply: () => boolean = () => true): Promise => { - const fileAttachments: string[] = [] - const folderPaths: string[] = [] + const results: ComposerDropItemResult[] = [] for (const filePath of paths) { try { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) const stat = await window.api.fs.stat({ filePath }) - if (stat.isDirectory) { - folderPaths.push(filePath) - } else { - fileAttachments.push(filePath) - } - } catch { - // Skip paths we cannot authorize or stat. + results.push({ + status: 'imported', + destPath: filePath, + kind: stat.isDirectory ? 'directory' : 'file' + }) + } catch (error) { + results.push(localDropFailure(readIpcErrorMessage(error))) } } if (!canApply()) { return } - addComposerAttachments(fileAttachments) - insertComposerFolderPaths(folderPaths) + const dropResult = collectComposerDropResult(results) + addComposerAttachments(dropResult.filePaths) + insertComposerFolderPaths(dropResult.folderPaths) + if (dropResult.failureCount > 0) { + showComposerDropFailureToast({ + failureCount: dropResult.failureCount, + total: paths.length, + commonFailure: dropResult.commonFailure + }) + } }, [addComposerAttachments, insertComposerFolderPaths] ) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 8707f0d0adb..8fb5d19446c 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2535,6 +2535,14 @@ } }, "hooks": { + "useComposerState": { + "attachSkipMissing": "No longer at its original path.", + "attachSkipPermissionDenied": "Permission denied.", + "attachSkipSymlink": "Symbolic links cannot be attached.", + "attachSkipUnsupported": "Unsupported file type.", + "dropPartiallyAttached_one": "{{failureCount}} of {{count}} item could not be attached.", + "dropPartiallyAttached_other": "{{failureCount}} of {{count}} items could not be attached." + }, "useIpcEvents": { "60428567b4": "Local terminal reveal is unavailable while a remote runtime is active", "f6300deb8b": "New Browser Tab" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 696e7f5ed7f..4d30c7941a7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -990,14 +990,20 @@ "useComposerState": { "7eb3f44ff7": "Selected agent is disabled. Choose an enabled agent before creating.", "b2ead86962": "Failed to resolve PR base.", - "a9ff236145": "Some attachments could not be uploaded.", "3db83fc58a": "No project path is available on this host for attachments.", "ba6cb77082": "Failed to connect to project.", "chooseOrAddProjectBeforeWorkspace": "Choose or add a project before creating a workspace.", "folderWorkspaceCreateFailedTitle": "Folder workspace creation failed", "folderWorkspaceCreateFailedMessage": "The folder workspace could not be created. Check the error details above, then try again.", "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior.", - "5f3d2c8a1b": "Failed to resolve MR base." + "5f3d2c8a1b": "Failed to resolve MR base.", + "dropPartiallyAttached": "{{failureCount}} of {{count}} items could not be attached.", + "dropPartiallyAttached_one": "{{failureCount}} of {{count}} item could not be attached.", + "dropPartiallyAttached_other": "{{failureCount}} of {{count}} items could not be attached.", + "attachSkipMissing": "No longer at its original path.", + "attachSkipSymlink": "Symbolic links cannot be attached.", + "attachSkipPermissionDenied": "Permission denied.", + "attachSkipUnsupported": "Unsupported file type." }, "useGlobalFileDrop": { "38c9f034ff": "Failed to upload dropped files.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 61c3f812f5f..2a47a73502d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -713,7 +713,6 @@ "useComposerState": { "7eb3f44ff7": "El agente seleccionado está deshabilitado. Elige un agente habilitado antes de crear.", "b2ead86962": "No se pudo resolver la base del PR.", - "a9ff236145": "Algunos archivos adjuntos no se pudieron cargar.", "3db83fc58a": "No hay ninguna ruta de proyecto remoto disponible para los archivos adjuntos.", "ba6cb77082": "No se pudo conectar al proyecto.", "chooseOrAddProjectBeforeWorkspace": "Elige o agrega un proyecto antes de crear un espacio de trabajo.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c21e08e7a5a..3a56fce87a6 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -835,7 +835,6 @@ "useComposerState": { "7eb3f44ff7": "L'agent sélectionné est désactivé. Choisissez un agent activé avant de créer.", "b2ead86962": "Échec de la résolution de la base de la PR.", - "a9ff236145": "Certaines pièces jointes n'ont pas pu être envoyées.", "3db83fc58a": "Aucun chemin de projet n'est disponible sur cet hôte pour les pièces jointes.", "ba6cb77082": "Échec de la connexion au projet.", "chooseOrAddProjectBeforeWorkspace": "Choisissez ou ajoutez un projet avant de créer un espace de travail.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 422e2b39fbc..696135a7669 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -713,7 +713,6 @@ "useComposerState": { "7eb3f44ff7": "選択した Agent は無効です。作成する前に、有効な Agent を選択してください。", "b2ead86962": "PR ベースを解決できませんでした。", - "a9ff236145": "一部の添付ファイルをアップロードできませんでした。", "3db83fc58a": "このホスト上に、添付に使用できるプロジェクトパスがありません。", "ba6cb77082": "プロジェクトへの接続に失敗しました。", "chooseOrAddProjectBeforeWorkspace": "ワークスペースを作成する前に、プロジェクトを選択または追加してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 45d2d30a225..5fd1e6addb8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -716,7 +716,6 @@ "useComposerState": { "7eb3f44ff7": "선택한 agent가 비활성화되었습니다. 생성하기 전에 활성화된 agent를 선택하세요.", "b2ead86962": "PR 기반을 해결하지 못했습니다.", - "a9ff236145": "일부 첨부파일을 업로드할 수 없습니다.", "3db83fc58a": "이 호스트에 첨부 파일에 사용할 수 있는 프로젝트 경로가 없습니다.", "ba6cb77082": "프로젝트에 연결하지 못했습니다.", "chooseOrAddProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 선택하거나 추가하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f64188d7201..125876be1a0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -716,7 +716,6 @@ "useComposerState": { "7eb3f44ff7": "所选智能体已禁用。创建之前选择启用的智能体。", "b2ead86962": "无法解析 PR 基础引用。", - "a9ff236145": "部分附件无法上传。", "3db83fc58a": "没有可用于附件的远程项目路径。", "ba6cb77082": "无法连接到项目。", "chooseOrAddProjectBeforeWorkspace": "创建工作区前,请选择或添加项目。", diff --git a/src/renderer/src/lib/ipc-error.test.ts b/src/renderer/src/lib/ipc-error.test.ts index 52d789f0027..cf7ba9d6817 100644 --- a/src/renderer/src/lib/ipc-error.test.ts +++ b/src/renderer/src/lib/ipc-error.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { extractIpcErrorMessage, readIpcErrorDetail, readIpcErrorMessage } from './ipc-error' +import { + compactIpcErrorMessage, + extractIpcErrorMessage, + readIpcErrorDetail, + readIpcErrorMessage +} from './ipc-error' describe('readIpcErrorMessage', () => { it('strips the Electron invoke wrapper Electron adds to a rejected handler', () => { @@ -45,6 +50,16 @@ describe('readIpcErrorMessage', () => { }) }) +describe('compactIpcErrorMessage', () => { + it('normalizes string error fields without manufacturing an Error', () => { + expect( + compactIpcErrorMessage( + "Error invoking remote method 'files:import': Error: permission denied\nstack" + ) + ).toBe('permission denied') + }) +}) + describe('extractIpcErrorMessage', () => { it('unwraps the same way readIpcErrorMessage does', () => { expect( diff --git a/src/renderer/src/lib/ipc-error.ts b/src/renderer/src/lib/ipc-error.ts index d9ef8a364eb..020bc7e9629 100644 --- a/src/renderer/src/lib/ipc-error.ts +++ b/src/renderer/src/lib/ipc-error.ts @@ -2,19 +2,20 @@ const IPC_INVOKE_PREFIX = /Error invoking remote method '[^']*':\s*(?:Error:\s*)?/ const IPC_HANDLER_PREFIX = /Error occurred in handler for '[^']*':\s*(?:Error:\s*)?/ +function unwrapIpcErrorMessage(message: string): string | undefined { + const detail = message.replace(IPC_INVOKE_PREFIX, '').replace(IPC_HANDLER_PREFIX, '').trim() + return detail || undefined +} + +export function compactIpcErrorMessage(message: string): string | undefined { + return unwrapIpcErrorMessage(message)?.split('\n')[0]?.trim() || undefined +} export function readIpcErrorDetail(error: unknown): string | undefined { - if (!(error instanceof Error)) { - return undefined - } - const message = error.message - .replace(IPC_INVOKE_PREFIX, '') - .replace(IPC_HANDLER_PREFIX, '') - .trim() - return message || undefined + return error instanceof Error ? unwrapIpcErrorMessage(error.message) : undefined } export function readIpcErrorMessage(error: unknown): string | undefined { - return readIpcErrorDetail(error)?.split('\n')[0]?.trim() || undefined + return error instanceof Error ? compactIpcErrorMessage(error.message) : undefined } // Preserve the legacy contract: wrapped errors are compact, while plain errors retain detail. diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index cfbec0ac146..2aebfbcfa8c 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -1,4 +1,5 @@ import { basename, joinPath } from '@/lib/path' +import type { ImportItemResult } from '../../../shared/filesystem-import-result-types' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision' @@ -21,31 +22,12 @@ import { import { getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -type RuntimeImportResult = - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - export async function importExternalPathsToRuntime( context: RuntimeFileOperationArgs, sourcePaths: string[], destinationDir: string, options?: { ensureDestinationDir?: boolean; assertCurrent?: () => void } -): Promise<{ results: RuntimeImportResult[] }> { +): Promise<{ results: ImportItemResult[] }> { const target = getActiveRuntimeTarget(context.settings) if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { return window.api.fs.importExternalPaths( @@ -89,7 +71,7 @@ export async function importExternalPathsToRuntime( importSession.assertCurrent() const staged = await window.api.fs.stageExternalPathsForRuntimeUpload({ sourcePaths }) importSession.assertCurrent() - const results: RuntimeImportResult[] = [] + const results: ImportItemResult[] = [] const reservedNames = new Set() await ensureRuntimeDirectory(context, destinationDir, importSession) diff --git a/src/main/ipc/filesystem-import-result-types.ts b/src/shared/filesystem-import-result-types.ts similarity index 81% rename from src/main/ipc/filesystem-import-result-types.ts rename to src/shared/filesystem-import-result-types.ts index 1d3e1a3fcad..206570e9a33 100644 --- a/src/main/ipc/filesystem-import-result-types.ts +++ b/src/shared/filesystem-import-result-types.ts @@ -1,7 +1,7 @@ import type { StagedRuntimeUploadEntry, StagedRuntimeUploadSource -} from '../../shared/runtime-upload-staging-contract' +} from './runtime-upload-staging-contract' export type ImportSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' @@ -11,8 +11,6 @@ export type ResolveDroppedPathsResult = { failed: { sourcePath: string; reason: string }[] } -// ─── External Import Types ────────────────────────────────────────── - export type ImportItemResult = | { sourcePath: string From 6cb56432418a8b8d8626577466b4cbd2c3dc3336 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 14 Sep 2026 16:36:06 -0700 Subject: [PATCH 35/43] fix(deps): migrate Tiptap security updates with Markdown compatibility guards (#19376) * chore(deps): evaluate coordinated Tiptap security migration * fix(editor): adapt link ranking and initialization for Tiptap 3.31 * fix(editor): preserve literal Markdown through Tiptap serialization * test(editor): cover literal saves in local folder and paired workspaces * test(editor): reselect folder after closing its final tab * perf(editor): avoid repeated inline source-marker lookahead scans * refactor(editor): inline redundant HTML match wrapper * test(chat): await Tiptap React skill-pill rendering --------- Co-authored-by: m4air Co-authored-by: m4air --- package.json | 36 +- pnpm-lock.yaml | 647 +++++++++--------- .../isolated-markdown-extension-for-tests.ts | 7 +- .../components/editor/raw-markdown-html.ts | 14 +- .../editor/rich-markdown-doc-link.ts | 3 +- .../editor/rich-markdown-extension.test.ts | 61 ++ .../editor/rich-markdown-extension.ts | 28 + .../editor/rich-markdown-extensions.ts | 12 +- .../rich-markdown-html-superscript-link.ts | 7 +- ...arkdown-inline-source-tokenization.test.ts | 22 + .../rich-markdown-list-tokenizers.test.ts | 17 + ...ich-markdown-literal-serialization.test.ts | 101 +++ .../rich-markdown-literal-serialization.ts | 72 ++ .../editor/rich-markdown-ordered-list.ts | 6 +- .../editor/rich-markdown-source-transport.ts | 5 + .../NativeChatPromptEditor.test.tsx | 12 +- .../e2e/markdown-literal-save-reopen.spec.ts | 119 ++++ 17 files changed, 802 insertions(+), 367 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-extension.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-extension.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-inline-source-tokenization.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-literal-serialization.ts create mode 100644 tests/e2e/markdown-literal-save-reopen.spec.ts diff --git a/package.json b/package.json index e48079f8a1c..a9f12759f74 100644 --- a/package.json +++ b/package.json @@ -205,24 +205,24 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@tiptap/extension-code-block": "^3.22.5", - "@tiptap/extension-code-block-lowlight": "^3.22.5", - "@tiptap/extension-details": "^3.22.5", - "@tiptap/extension-image": "^3.22.5", - "@tiptap/extension-link": "^3.22.5", - "@tiptap/extension-list": "^3.22.5", - "@tiptap/extension-mathematics": "3.22.5", - "@tiptap/extension-placeholder": "^3.22.5", - "@tiptap/extension-table": "3.22.4", - "@tiptap/extension-table-cell": "3.22.4", - "@tiptap/extension-table-header": "3.22.4", - "@tiptap/extension-table-row": "3.22.4", - "@tiptap/extension-task-item": "^3.22.5", - "@tiptap/extension-task-list": "^3.22.5", - "@tiptap/markdown": "^3.22.5", - "@tiptap/pm": "^3.22.5", - "@tiptap/react": "^3.22.5", - "@tiptap/starter-kit": "^3.22.5", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-code-block-lowlight": "3.31.3", + "@tiptap/extension-details": "3.31.3", + "@tiptap/extension-image": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-mathematics": "3.31.3", + "@tiptap/extension-placeholder": "3.31.3", + "@tiptap/extension-table": "3.31.3", + "@tiptap/extension-table-cell": "3.31.3", + "@tiptap/extension-table-header": "3.31.3", + "@tiptap/extension-table-row": "3.31.3", + "@tiptap/extension-task-item": "3.31.3", + "@tiptap/extension-task-list": "3.31.3", + "@tiptap/markdown": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/react": "3.31.3", + "@tiptap/starter-kit": "3.31.3", "@types/node": "^25.6.0", "@types/proper-lockfile": "^4.1.4", "@types/qrcode": "^1.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d93fec995ef..b2f6425aafd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,59 +238,59 @@ importers: specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) '@tiptap/extension-code-block': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-code-block-lowlight': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(highlight.js@11.11.1)(lowlight@3.3.0) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.1)(lowlight@3.3.0) '@tiptap/extension-details': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)))(@tiptap/pm@3.31.3) '@tiptap/extension-image': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-link': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-list': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-mathematics': - specifier: 3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(katex@0.16.45) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(katex@0.16.45) '@tiptap/extension-placeholder': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-table': - specifier: 3.22.4 - version: 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-table-cell': - specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-table-header': - specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-table-row': - specifier: 3.22.4 - version: 3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-task-item': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-task-list': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/markdown': - specifier: ^3.22.5 - version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/pm': - specifier: ^3.22.5 - version: 3.22.5 + specifier: 3.31.3 + version: 3.31.3 '@tiptap/react': - specifier: ^3.22.5 - version: 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 3.31.3 + version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tiptap/starter-kit': - specifier: ^3.22.5 - version: 3.22.5 + specifier: 3.31.3 + version: 3.31.3 '@types/node': specifier: ^25.6.0 version: 25.9.5 @@ -2910,229 +2910,230 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tiptap/core@3.22.5': - resolution: {integrity: sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==} + '@tiptap/core@3.31.3': + resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} peerDependencies: - '@tiptap/pm': 3.22.5 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-blockquote@3.22.5': - resolution: {integrity: sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==} + '@tiptap/extension-blockquote@3.31.3': + resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.22.5': - resolution: {integrity: sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==} + '@tiptap/extension-bold@3.31.3': + resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-bubble-menu@3.22.5': - resolution: {integrity: sha512-yrNlFQQJY5MmhBpmD8tnmaSmyUQrEvgyPKa3bzVeWEhDSG1CW4A0ZSMx3hrA9yFO0HWfw3IJmvSCycEZQBalpQ==} + '@tiptap/extension-bubble-menu@3.31.3': + resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bullet-list@3.22.5': - resolution: {integrity: sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==} + '@tiptap/extension-bullet-list@3.31.3': + resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-code-block-lowlight@3.22.5': - resolution: {integrity: sha512-lT0SxhjkDL1tKSeVDduV+SJ6kHdNFcbYBaUAwTufRtDt8FIYcSX6tWj5cPEXOFrC0PlJu7ybCnTEbXBdFP8Bnw==} + '@tiptap/extension-code-block-lowlight@3.31.3': + resolution: {integrity: sha512-DN21CYEL4bm01vyB/wiyDPKrpiyalI5okTWx90jRuTqRzbnkPlnng1Vdo9L220w3GXct5Exe6iI01E7F19bC5A==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/extension-code-block': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/extension-code-block': 3.31.3 + '@tiptap/pm': 3.31.3 highlight.js: ^11 lowlight: ^2 || ^3 - '@tiptap/extension-code-block@3.22.5': - resolution: {integrity: sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==} + '@tiptap/extension-code-block@3.31.3': + resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.22.5': - resolution: {integrity: sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==} + '@tiptap/extension-code@3.31.3': + resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-details@3.22.5': - resolution: {integrity: sha512-+vg7wSO9DL8veAzC4jlHu4lQ4qL2iRxj/ONTfP+jnffN0TzrjAdkMLCUEX059gYyks22AXgoI5vEzWs0K7yeuw==} + '@tiptap/extension-details@3.31.3': + resolution: {integrity: sha512-J5kdZy31wb97iKUR4sDsX0sEyRPlTijKKRAnVbInY2aNdFuNBTBdMPmblEJztxfMN+TmDrU2kCVv72J9B1SrJg==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/extension-text-style': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/extension-text-style': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-document@3.22.5': - resolution: {integrity: sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==} + '@tiptap/extension-document@3.31.3': + resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-dropcursor@3.22.5': - resolution: {integrity: sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==} + '@tiptap/extension-dropcursor@3.31.3': + resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} peerDependencies: - '@tiptap/extensions': 3.22.5 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-floating-menu@3.22.5': - resolution: {integrity: sha512-dhem4sTPhyQgQ+pFp2Oud4k4FSQz9PVMgeQAC9288SmGwxBkJNveDAw6sKTMrumqDvwkJrtslXIupq9TZYQnzg==} + '@tiptap/extension-floating-menu@3.31.3': + resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-gapcursor@3.22.5': - resolution: {integrity: sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==} + '@tiptap/extension-gapcursor@3.31.3': + resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} peerDependencies: - '@tiptap/extensions': 3.22.5 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-hard-break@3.22.5': - resolution: {integrity: sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==} + '@tiptap/extension-hard-break@3.31.3': + resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-heading@3.22.5': - resolution: {integrity: sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==} + '@tiptap/extension-heading@3.31.3': + resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-horizontal-rule@3.22.5': - resolution: {integrity: sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==} + '@tiptap/extension-horizontal-rule@3.31.3': + resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-image@3.22.5': - resolution: {integrity: sha512-ezMzA6w6UsPesQp6fxTQojI/IkGJLmkwR/VGTimva7sudP3HdSW8k3SGBkjfvp0L2xqUrC/l4nWOchu01A/xtQ==} + '@tiptap/extension-image@3.31.3': + resolution: {integrity: sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-italic@3.22.5': - resolution: {integrity: sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==} + '@tiptap/extension-italic@3.31.3': + resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-link@3.22.5': - resolution: {integrity: sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==} + '@tiptap/extension-link@3.31.3': + resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-list-item@3.22.5': - resolution: {integrity: sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==} + '@tiptap/extension-list-item@3.31.3': + resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list-keymap@3.22.5': - resolution: {integrity: sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==} + '@tiptap/extension-list-keymap@3.31.3': + resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list@3.22.5': - resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==} + '@tiptap/extension-list@3.31.3': + resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-mathematics@3.22.5': - resolution: {integrity: sha512-ld2xoFHKyl4Qs+rgu3wn1UZBTsgDApEz2PD17E/XWlVXHO4KoiBOcLhrc5L9SL+aKOBXXmC/Ex1d+0hCptsBbg==} + '@tiptap/extension-mathematics@3.31.3': + resolution: {integrity: sha512-R19lI2hLSXkQ+aqKvvyrDdI+HnmFtmfTVotrQ5W06i8WxVqRSq5UpsPIRCoeUeBL8uCvKinfmSVJyX2V80Bayg==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 - katex: ^0.16.4 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + katex: ^0.16.4 || ^0.17.0 || ^0.18.0 - '@tiptap/extension-ordered-list@3.22.5': - resolution: {integrity: sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==} + '@tiptap/extension-ordered-list@3.31.3': + resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-paragraph@3.22.5': - resolution: {integrity: sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==} + '@tiptap/extension-paragraph@3.31.3': + resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-placeholder@3.22.5': - resolution: {integrity: sha512-MZAohQ3FCS763BkhGXgaWRya6WruZjwRwEAkXP8vkxbERzl2OJRjniS4uXCWzAlRb3ttE103SnY7LMdM8FvsXw==} + '@tiptap/extension-placeholder@3.31.3': + resolution: {integrity: sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==} peerDependencies: - '@tiptap/extensions': 3.22.5 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-strike@3.22.5': - resolution: {integrity: sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==} + '@tiptap/extension-strike@3.31.3': + resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-table-cell@3.22.4': - resolution: {integrity: sha512-uvFegCc1UQYK2nfIV2sIHg+hzLIMroJJm00XomzBgC1w/eSO7Ui8APiDh/baBcTPpCSU3SLiQLTgx7AU7oE3pg==} + '@tiptap/extension-table-cell@3.31.3': + resolution: {integrity: sha512-5nueKR/p/IX6B4etWqHjyRsjNfDf6dJZaRgfw1/1l90CJe2h3tTZk4JbUKWN3Mo4FzZuYKzmU3fxcZ8pSVOeJQ==} peerDependencies: - '@tiptap/extension-table': 3.22.4 + '@tiptap/extension-table': 3.31.3 - '@tiptap/extension-table-header@3.22.4': - resolution: {integrity: sha512-V4kLLWeRdc/I+IXiXZZhLAjsaHHiJWuLXTuOtZRDrCxQUiFLi4AgNg1DPQ09JAANkEWDhXq3x6BoUXaFwumbEw==} + '@tiptap/extension-table-header@3.31.3': + resolution: {integrity: sha512-sstVtNQiBYX4P16vlhwBYPsthxDnodHWQfGq0EUQM40Z7H796Lgh9t1qYtu8yARQuHKzvINRwJCSy6DZ8YTDtQ==} peerDependencies: - '@tiptap/extension-table': 3.22.4 + '@tiptap/extension-table': 3.31.3 - '@tiptap/extension-table-row@3.22.4': - resolution: {integrity: sha512-9tdS6jgS6DqUu5TpEmNrRoo/DL5Xam0PyrQaUEXUC+ssci+bMRCJ8PAWMcunNsI9NKf/Tb3wYrv6hGFChaT9uA==} + '@tiptap/extension-table-row@3.31.3': + resolution: {integrity: sha512-up6tDK+hYVTFDeJ3XqKO0WJqBzyXNfGxMoimo5h83jrq9dcNh0oPCOzglZ7rdo8GH9I3VJ2fNKxxU2Eg2HcMoQ==} peerDependencies: - '@tiptap/extension-table': 3.22.4 + '@tiptap/extension-table': 3.31.3 - '@tiptap/extension-table@3.22.4': - resolution: {integrity: sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==} + '@tiptap/extension-table@3.31.3': + resolution: {integrity: sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==} peerDependencies: - '@tiptap/core': 3.22.4 - '@tiptap/pm': 3.22.4 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-task-item@3.22.5': - resolution: {integrity: sha512-OVJKiq67lU+RiC6slIhhgTJBlP/Vads6MZ7Ld5wxzCtWMdGKDuzQ1dgF7vrMEs7mhSeSH3phNcIdQ5ypYftZ9w==} + '@tiptap/extension-task-item@3.31.3': + resolution: {integrity: sha512-gCWvvXsCzi9tVFXPqKlkbU81XxtIdfjGy7FM9QFRKiWAvq5uv+Q5nvwrCyMVyP4bA7zRGWFaPzwjL68djgzoBw==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-task-list@3.22.5': - resolution: {integrity: sha512-SfZeJSALtFODs0i3fml1TSi4vQ4Uopu0p/LndK+mX5FGNBtNmWiy7Wr5cH03ANfzj8c2EzfGIyH+F2/V0HLK9g==} + '@tiptap/extension-task-list@3.31.3': + resolution: {integrity: sha512-3WgVzmfEnDbmxbjUBlvTiKpT53KSBnnYhXUVQxM0nz1vpoWG8QifhsnCVsNI6TZmhgR0x/jY+UW9Hc/WLF8DkQ==} peerDependencies: - '@tiptap/extension-list': 3.22.5 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-text-style@3.22.5': - resolution: {integrity: sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==} + '@tiptap/extension-text-style@3.31.3': + resolution: {integrity: sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-text@3.22.5': - resolution: {integrity: sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==} + '@tiptap/extension-text@3.31.3': + resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extension-underline@3.22.5': - resolution: {integrity: sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==} + '@tiptap/extension-underline@3.31.3': + resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} peerDependencies: - '@tiptap/core': 3.22.5 + '@tiptap/core': 3.31.3 - '@tiptap/extensions@3.22.5': - resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==} + '@tiptap/extensions@3.31.3': + resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/markdown@3.22.5': - resolution: {integrity: sha512-lLuAySaY5EYNYLe7e4507B9yQMAEDJdOKy0g85UNFV8giorYLQx56aV2O94Qb9gv3egs5inkwVRNfeJzOWAwig==} + '@tiptap/markdown@3.31.3': + resolution: {integrity: sha512-rBbYSxasseUoaBo15C8Yoaqafo7mJJzxwAwV9Z1OpLBvOroW9nQ0EbOdqaSYRw3/d9pP71B/3LNSzKyfyy+dpQ==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/pm@3.22.5': - resolution: {integrity: sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==} + '@tiptap/pm@3.31.3': + resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} - '@tiptap/react@3.22.5': - resolution: {integrity: sha512-36WHEs+vPmB//V1ff7Ujcnpz7Ey5g8lhpI/0+hoanSbdiPMTQ7qZVWwMovIkMKDlqWVp2fxBgeYM1861jyFzTw==} + '@tiptap/react@3.31.3': + resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} peerDependencies: - '@tiptap/core': 3.22.5 - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@3.22.5': - resolution: {integrity: sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==} + '@tiptap/starter-kit@3.31.3': + resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -5207,8 +5208,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkifyjs@4.3.2: - resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} lint-staged@16.4.0: resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} @@ -5939,11 +5940,14 @@ packages: prosemirror-history@1.5.0: resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + prosemirror-keymap@1.2.3: resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} - prosemirror-model@1.25.4: - resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + prosemirror-model@1.25.11: + resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} @@ -5957,8 +5961,8 @@ packages: prosemirror-transform@1.12.0: resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - prosemirror-view@1.41.8: - resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + prosemirror-view@1.42.3: + resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} @@ -9192,200 +9196,202 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tiptap/core@3.22.5(@tiptap/pm@3.22.5)': + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/pm': 3.22.5 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-blockquote@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-bubble-menu@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-bullet-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-code-block-lowlight@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(highlight.js@11.11.1)(lowlight@3.3.0)': + '@tiptap/extension-code-block-lowlight@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.1)(lowlight@3.3.0)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 highlight.js: 11.11.1 lowlight: 3.3.0 - '@tiptap/extension-code-block@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-details@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)))(@tiptap/pm@3.22.5)': + '@tiptap/extension-details@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-text-style': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-text-style': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-document@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-dropcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-floating-menu@3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-gapcursor@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-hard-break@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-heading@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-horizontal-rule@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-image@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-image@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-italic@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-link@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 - linkifyjs: 4.3.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + linkifyjs: 4.3.3 - '@tiptap/extension-list-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list-keymap@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-mathematics@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(katex@0.16.45)': + '@tiptap/extension-mathematics@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(katex@0.16.45)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 katex: 0.16.45 - '@tiptap/extension-ordered-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-paragraph@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-placeholder@3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-placeholder@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-table-cell@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-table-cell@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-table-header@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-table-header@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-table-row@3.22.4(@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-table-row@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-table': 3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-table@3.22.4(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-task-item@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-task-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-task-list@3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5))': + '@tiptap/extension-task-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-text-style@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-text@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-underline@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))': + '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/markdown@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)': + '@tiptap/markdown@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 marked: 17.0.6 - '@tiptap/pm@3.22.5': + '@tiptap/pm@3.31.3': dependencies: prosemirror-changeset: 2.4.1 prosemirror-commands: 1.7.1 prosemirror-dropcursor: 1.8.2 prosemirror-gapcursor: 1.4.1 prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 - '@tiptap/react@3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@tiptap/react@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/use-sync-external-store': 0.0.6 @@ -9394,37 +9400,37 @@ snapshots: react-dom: 19.2.8(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-floating-menu': 3.22.5(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) + '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.22.5': + '@tiptap/starter-kit@3.31.3': dependencies: - '@tiptap/core': 3.22.5(@tiptap/pm@3.22.5) - '@tiptap/extension-blockquote': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-bold': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-bullet-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-code': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-code-block': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-document': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-dropcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-gapcursor': 3.22.5(@tiptap/extensions@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-hard-break': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-heading': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-horizontal-rule': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-italic': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-link': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/extension-list-item': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-list-keymap': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-ordered-list': 3.22.5(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)) - '@tiptap/extension-paragraph': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-strike': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-text': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extension-underline': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5)) - '@tiptap/extensions': 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5) - '@tiptap/pm': 3.22.5 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@ts-morph/common@0.27.0': dependencies: @@ -11621,7 +11627,7 @@ snapshots: lines-and-columns@1.2.4: {} - linkifyjs@4.3.2: {} + linkifyjs@4.3.3: {} lint-staged@16.4.0(patch_hash=7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673): dependencies: @@ -12641,7 +12647,7 @@ snapshots: prosemirror-commands@1.7.1: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 @@ -12649,58 +12655,63 @@ snapshots: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 prosemirror-gapcursor@1.4.1: dependencies: prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 rope-sequence: 1.3.4 + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-keymap@1.2.3: dependencies: prosemirror-state: 1.4.4 w3c-keyname: 2.2.8 - prosemirror-model@1.25.4: + prosemirror-model@1.25.11: dependencies: orderedmap: 2.1.1 prosemirror-schema-list@1.5.1: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 prosemirror-state@1.4.4: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 prosemirror-tables@1.8.5: dependencies: prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.8 + prosemirror-view: 1.42.3 prosemirror-transform@1.12.0: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 - prosemirror-view@1.41.8: + prosemirror-view@1.42.3: dependencies: - prosemirror-model: 1.25.4 + prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 diff --git a/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts b/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts index 46ed021ee2e..2576bb31b21 100644 --- a/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts +++ b/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts @@ -1,9 +1,10 @@ -import { Markdown } from '@tiptap/markdown' +import { createRichMarkdownExtension } from './rich-markdown-extension' import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' export function createIsolatedMarkdownExtensionForTests() { - return Markdown.configure({ - marked: createRichMarkdownEditorCodec().marked, + const codec = createRichMarkdownEditorCodec() + return createRichMarkdownExtension(codec).configure({ + marked: codec.marked, markedOptions: { gfm: true } }) } diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts index 1262d5254ab..edcf8715808 100644 --- a/src/renderer/src/components/editor/raw-markdown-html.ts +++ b/src/renderer/src/components/editor/raw-markdown-html.ts @@ -7,16 +7,14 @@ import type { RichMarkdownSourceKind, RichMarkdownSourceTransport } from './rich-markdown-source-transport' -import { isReservedRichMarkdownTransportBody } from './rich-markdown-source-transport' +import { + isReservedRichMarkdownTransportBody, + skipInlineTransportStartScan +} from './rich-markdown-source-transport' import { matchHtmlSuperscriptLinkSource } from './rich-markdown-html-superscript-link-source' const INLINE_HTML_PATTERN = /^|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/ -function matchInlineHtml(src: string): string | null { - const match = src.match(INLINE_HTML_PATTERN) - return match?.[0] ?? null -} - function isEscaped(content: string, index: number): boolean { let backslashCount = 0 for (let i = index - 1; i >= 0 && content[i] === '\\'; i -= 1) { @@ -186,7 +184,7 @@ export function encodeRawMarkdownHtmlForRichEditor( const inlineHtml = normalizedContent.startsWith('