Commit Graph
32 Commits
Author SHA1 Message Date
Neil 1bec53ceb2 Reduce repeated CI setup and overlap mobile typechecks (#25359)
* Measure remaining CI import, diagnostic and checkout savings

* Qualify remaining CI candidates on hosted runners

* Qualify independent mobile typecheck overlap on Actions

* Keep explicit RPC test registries from loading unused methods

* Qualify complete RPC registry cohort and mobile cancellation

* Promote measured CI setup and typecheck savings

* Recognize the shared RPC test guard in lint policy

* Align the mobile barrier contract with independent typechecks
2026-10-04 19:58:35 -07:00
NeilandOrca Integration Recovery 77ad467ebb fix(ci): prevent concurrent pnpm refresh during mobile typechecks (#24776)
* fix(ci): run mobile typechecks without concurrent dependency refresh

* test(ci): check effective Linux E2E package list

* test(ci): preserve the mobile production compiler barrier

---------

Co-authored-by: Orca Integration Recovery <orca-validation@invalid.example>
2026-10-02 21:52:36 -07:00
Neil 328caa2160 fix(git): reduce queries and preserve data across execution hosts (#24602)
* fix(git): reduce queries and preserve data across execution hosts

* fix(ci): exercise pinned Git and serialize mobile dependency entrypoints

* fix(relay): preserve fresh diff retries after hung shared reads

* test(git): wait for fetch barrier before canceling preparation

* fix(i18n): describe index-preserving discard in every locale

* fix(git): retain clone diagnostics and allow WSL policy startup

* test(git): refresh default-base and branch-safety fixtures
2026-10-02 18:05:37 -07:00
Neil b94c75c4bd Reuse pnpm verification records in Alpine CI (#24817)
* ci: reuse pnpm verification records in Alpine builders

* ci: qualify consumers of the verification restore action

* ci: match Linux verification cache archive paths
2026-10-02 13:54:04 -07:00
Brennan Benson e594cb06af test(mobile): record RPC goldens without a pinned commit, and check recorded requests against the desktop's params rules (#23732)
* test(mobile): add rpc:diff to decode what a recording change moved

The RPC recording goldens are content-addressed JSON, so their raw git diff is
pool hashes. `pnpm --dir mobile rpc:diff [<base>]` decodes both sides and prints,
per golden, the checkpoint, field and JSON path that moved with both values,
grouped across checkpoints, plus added and removed goldens. `--summary <file>`
appends a Markdown report capped for GitHub's step-summary limit.

It reads any pooled format, so it can prove the next commit's format change
moves no recorded value. Checkpoints are matched by occurrence because an id can
repeat within one golden.

This commit adds files under the recorder directory, which moves the header
digest every golden pins; the next commit removes that header.

* test(mobile): record RPC goldens without a pinned commit or input digests

Every golden carried a pinned `baseline` commit plus digests of the recorder,
its mount adapter and its scenario, and the record script refused to run unless
the product tree matched the pin. So every behaviour change repinned to its own
branch commit and rewrote all ~790 files, the squash made that commit
unreachable, and main's pin job stayed red until a hand-made repin pull request
landed (22 of them in 12 days). The digests could only fail when an input moved
and the recording did not, which is exactly the change that carries no
information; every run already re-derives each golden from the current tree and
compares it.

Format 6 keeps the format version, operation, family, named deltas, the value
pool and the recording. Removed: the pin and fence, the three digest modules and
their test, the pin guard and its CI job, and the dead scenario `version` field
(the manifest reader now refuses `baseline` and `version` with a message).

- `pnpm --dir mobile rpc:record [<golden-id>...] [--prune]` records all or some
  goldens; orphans are listed, and deleted only with `--prune`. Every derived
  test title now starts with its golden id so an id selects it.
- `compareGolden` reports every difference in one failure (identity fields by
  name, the checkpoint list, each checkpoint/field/path grouped), keeps the
  final byte compare, and ends with the command to re-record that golden.
- `unhandled-recording.test.ts` now drives a detached rejection through
  `runRecording` into a checkpoint and the cleanup checkpoint; no golden carries
  one, and disconnecting the capture passed every suite before.
- Seam rules that existed only to keep a digest honest are gone; the
  mutant-reachability, register-completeness and one-exposure rules stay.
- CI: `Mobile tests on main` runs the whole mobile suite on every merge that
  touches mobile/, src/shared/, the root lockfile or the host RPC paths, since
  `verify` never runs on main. A new `Mobile RPC Recording Replay` workflow
  replays the recordings on pull requests that touch src/shared/ or the root
  lockfile without touching mobile/. `verify` writes the `rpc:diff` report to
  the job summary.

Proof: `rpc:diff` against the parent reports no recorded behaviour moved; each
golden only loses its ten header lines.

* test(mobile): check every recorded request against the host's params contract

The goldens script the host's replies, so a scenario could record a success
for a request the real host would refuse, and a desktop change that tightens a
params schema moved no golden at all.

`recorded-request-params.test.ts` parses every distinct request the corpus puts
on the wire with the host dispatcher's own `parseRpcRequestParams` and the
schema `rpc-params-catalog.generated.ts` binds to that method. It fails on a
method the host lacks, params it refuses, params sent to a method that takes
none (the dispatcher never reads them), and keys the schema silently strips
unless an inventory entry gives the reason; a stale entry fails too. Each rule
is also shown firing on a made-up request, since the corpus has no instance of
three of them. It imports the desktop dispatcher, so it sits beside the other
Node-side tests outside the RN test program, and the params-contract boundary
now exempts test files, which are never bundled.

It found twelve requests the host would refuse, all from invented fixture
values, not product code, fixed at their source:
- git.branchDiff sent `base-oid`/`head-oid`/`merge-base` where the host needs
  full object ids (diff-review and source-control adapters, and the branch
  compare replies in the manifest that feed them);
- an iOS push registration without `apnsEnvironment`, which a real iOS token
  always carries (`push-token.ts`); the adapter now defaults to `production`;
- `settings.update` given Linear's `assigned` filter as a GitHub preset, which
  the product type forbids; the scenario now picks `my-issues`;
- GitLab `projectRef` as a string where the host and the product type take
  `{ host, path }` (7 methods, 5 adapters and the manifest).

46 goldens move, and a decoded comparison of every one of them shows no change
other than those substitutions; `rpc:diff` lists them.

* ci(mobile): detect a mobile change without a SIGPIPE-prone grep pipe

Under the runner's pipefail, grep -q exiting on its first match SIGPIPEs git
diff on a long file list, so a large pull request touching mobile/ read as
uncovered and replayed the recordings a second time.

* test(mobile): drop comments that still describe the golden header and digests

Eleven adapters justified an import rule by the header a golden no longer
carries, and that rule's test is gone. The census failure now names the
rpc:record and --prune commands.

* test(mobile): refuse a golden that keeps a key no recording writes

Decoding dropped unknown top-level keys, so an old header left behind by a
hand-resolved merge conflict passed every compare unseen.

* ci(mobile): summarize RPC recording changes after a failed test step too

* test(mobile): stream rpc:record output instead of capturing it

A captured run stayed silent for its whole duration and clipped its tail,
where the failure summary sits, past 8 MB.

* test(ci): let the Ruby-gate contract skip the always-run RPC summary step

fef088d8f4 gave the summary step an `if: ${{ !cancelled() }}`, and this test lists every gated step in `verify` and expects each to be gated on the Ruby scope.

* test(mobile): replay only a golden file that is exactly what rpc:record writes

Replay compared two re-encodings of decoded values, so anything decoding drops (a leftover header
key, a hand edit) sat in the committed file uncompared; a key allow-list covered one case of that.
Replay now passes only if the file text equals the formatted golden for the run, sharing one
formatter with writeGolden, and keeps the field-level report as the failure message. The allow-list
goes; the value-based compareGolden stays for the bridged run, which has no file.

* test(mobile): end a corrupt or hand-edited golden's failure with the re-record command

A hand edit to a pooled value failed in decode with only "Golden value <hash> does not hash to its
pool key": no golden id and no command to fix it. readGolden now prefixes parse and decode failures
with the golden id and ends them with the rpc:record command. The rpc:diff header also said it
always exits 0; it exits non-zero when git or a golden cannot be read, and now says so.

* ci(mobile): run Mobile Checks on every src/shared and root lockfile change

Replaces the replay-only workflow: mobile imports hundreds of shared modules, so a shared edit can
move a golden or break mobile's typecheck, and the full job catches both before merge. A root
lockfile-only change skips the Ruby release checks, which read no root Node dependency.

* test(mobile): list or prune orphaned goldens even when the recording run fails

Orphans come from the manifest, not the run, so a failed or timed-out rpc:record still reports
them; the exit code stays non-zero. README: say what a failed replay reports (first differing
path per field, capped groups) and what rpc:diff compares with and without a base.

* test(mobile): pin the RPC recording goldens to LF so a CRLF checkout still replays

Replay now requires the committed golden text to equal exactly what rpc:record writes, which is
LF. A Windows checkout with core.autocrlf=true converted every golden to CRLF and failed all 790
with "holds the same recording but is not the file rpc:record writes for it".
2026-09-29 23:26:55 -07:00
Brennan Benson c5fc0c6f26 fix(ci): keep a squash-merged RPC recording pin reachable through its pull request (#23720)
* fix(ci): keep a squash-merged RPC recording pin reachable through its pull request

Main's "RPC recording pin" check has been red since #22762: that branch pinned
the recording corpus to its own commit 03995ae, and the squash-merge left that
commit out of main's history. Every behaviour-change squash did the same, and
each needed a hand-made repin PR to clear it (#23565, #23535, #23046 and more).

The guard now accepts a pin that is either in this history or in the head of the
pull request whose squash wrote it into the manifest. It finds that pull request
from the `(#n)` subject of the commit that added the pin and fetches
`refs/pull/<n>/head`, which GitHub keeps after the branch is deleted. The
reproduce step uses the same lookup, so it can still check the pinned tree out.

* fix(ci): give the recording pin lookup room to walk a blobless clone

In CI's blobless clone, `git log -S` fetches the manifest's blobs one commit at a
time, a few seconds each. Under the 30 s process default the walk was killed after
a handful of manifest commits, which main's history already exceeds (up to 7
manifest commits between a pin landing and the next pin change), and the guard
then failed with an empty "Could not find the commit that pinned" error. The
lookup and the pull request fetch now carry explicit budgets and say when they
timed out.

The not-an-ancestor instruction now names the pull request whose head was
checked, or says the commit that pinned it names none.

Adds the two merge-preview shapes the guard runs on: a branch opened after a
squash resolves main's pin through the squash's pull request, and a branch whose
rebase dropped its own pinned commit fails on its pull request instead of on main.

* fix(mobile): tell a missing recording pin apart from product drift

After a squash the pinned commit can live only in its pull request's head, so a
clone that never fetched it makes `git diff --quiet <baseline>` exit 128. The
recorder reported that as "Product sources or lockfile differ from the pinned
main baseline", which sends the developer to repin a tree that may match. It now
prints git's error and the command that fetches the pin.

* fix(ci): ask GitHub which pull request holds a squash-dropped recording pin

The recording pin guard found the pull request that keeps a squash-dropped
pin by walking main's first-parent history for the commit that wrote the pin
into the manifest and reading "(#n)" off its subject. A merger who edits the
squash title loses the number, and the push to main turns red anyway. That
already happened on main: of the 22 squashes that left a pin outside main's
history, #21674's title had no "(#n)".

The guard now asks GitHub for the pull requests associated with the pinned
commit (GET /repos/{owner}/{repo}/commits/{sha}/pulls) and, for each in turn,
fetches refs/pull/<n>/head and accepts only when git proves the pin is an
ancestor of that head. GitHub only nominates candidates, so a wrong answer can
fail the guard but never pass it. The endpoint named the right pull request
for all 22 historical cases, #21674 included, and names none for commits a
force-push orphaned.

This removes the pickaxe walk, its 600 s budget and its lazy blob fetches in
a blobless clone, the first-parent subtlety, and the subject regex. A revert
that restores an older pull-request-only pin now resolves too, because the
lookup is by the pin itself rather than by the commit that last wrote it.

CI passes the job token to both guard steps and grants the job
pull-requests: read. Local runs work without a token on this public repo and
send GITHUB_TOKEN or GH_TOKEN when set. A failed lookup throws with the HTTP
status, and names the rate limit when an unauthenticated call is refused.
2026-09-28 21:17:07 -07:00
Neil 0fe8974354 perf(ci): move six more jobs to the free ARM runner (#23594)
* perf(ci): move six more jobs to the free ARM runner

Follows the static-analysis move, which measured 172s to 128s. Each of these was
checked for an x86 requirement rather than assumed portable.

pr.yml:
  cross-version-wire        source-only, tagged checkout plus in-process vitest
  managed_hook_node18       Node 18 publishes linux-arm64; the per-platform
                            runtime files are read as data, so host arch is moot
  codex_index_heal_contract @openai/codex ships @openai/codex-linux-arm64
  shell_contracts           fish 4.x is published for noble/arm64, zsh is in the
                            arm64 archive, so the fatal fish-4 gate still holds

mobile.yml:
  verify                    209s of its 298s is Vitest; no Android SDK, emulator,
                            gradle, Hermes or Watchman, no docker, no artifacts.
                            Gemfile.lock lists the generic `ruby` platform, so
                            frozen bundler resolves without an aarch64-linux entry
  recording-pin             pure Node plus git; the golden comparison masks
                            `platform`

Left on x86 deliberately:
  package                   builds --x64 targets, its docker gates are
                            --platform linux/amd64, and it is where the glibc
                            floor check runs. node-pty's .symver pin is
                            arch-specific, so flipping would validate the arm64
                            pin and stop validating the shipped x64 one
  orcad_browser             Google ships no Linux arm64 Chrome
  mobile_web_app            same Chrome wall; its render check fails closed
  git_compatibility         its cache key carries runner.arch and the warmer is
                            x86, so flipping alone means a cold `make git` every
                            run
  relay_integration         no technical blocker, but x86 relay coverage is a
                            documented placement and the reusable workflow has no
                            per-job runner input
  e2e and the ssh lanes     the ssh jobs would silently retarget the tested
                            remote from linux-x64 to linux-arm64

* perf(ci): move xterm_patch_sync to ARM too

The patch check rebuilds 4 packages x 2 builds and byte-compares against the
checked-in bundles. Ran it on darwin-arm64: exit 0, in sync at 1362743 bytes,
with the full fetch-and-rebuild path exercised rather than a short-circuit. The
bundles were generated on Linux x64 and reproduce byte-for-byte on a different
arch and a different OS, so the output is host-independent.
2026-09-28 02:42:41 -07:00
OrcaWinandm4air ccd1e87287 Overlap independent CI checks with native Actions background steps (#23351)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 23:09:40 -07:00
OrcaWinandm4air b5dec85a4e ci: reuse mobile web route analysis and skip unrelated mobile tests (#23329)
* ci: share mobile route analysis and scope mobile test runs

* ci: cover mobile web runner process dependencies

* test: verify mobile web selectors through the new runner

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 21:37:40 -07:00
OrcaWinandm4air d20cb69c48 Optimize CI follow-up workflows (#23190)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 02:53:37 -07:00
OrcaWinandm4air d17a17684b Reduce redundant CI runs, pnpm uploads, and fixture startups (#23145)
* Reduce redundant CI runs, store uploads, and fixture processes

* Avoid repeating draft-independent mobile checks on readiness

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-26 01:17:51 -07:00
Jinwoo Hong f2fd18c820 test(mobile): make the bridged recording-corpus replay a CI gate and pin the C1 page closure (OTA phase C, C1.6) (#21533)
* test(mobile): the bridged corpus replay is a gate, not an opt-in flag (OTA phase C, C1.6)

`rpc-recording-through-bridge.test.ts` replays all 787 goldens through the page
bridge and pins how far that bridge is from byte-identical. It only ran when
`RPC_FOUNDATION_BRIDGE=1` was set, which CI did in a step of its own. A gate
whose CI job has to remember to set a variable is opt-in, and a branch that
widened the divergence with that step edited away would have been measured by
nobody.

The suite now runs by default and `RPC_FOUNDATION_BRIDGE=0` skips it, for a
local run that does not want the three minutes. `BRIDGED_PARITY_OFF` names the
one value that skips, so an unset or mistyped variable still runs the gate.

The dedicated CI step goes with it: `pnpm test` collects the file already, so
keeping the step would have run the same 788 tests twice in one job. Vitest
gives the file a worker beside the rest of the suite, so the marginal wall time
is a fraction of the ~3 min it takes alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the bridged verdict of every C1-page-closure golden by id (OTA phase C, C1.6)

The bridged replay certifies the corpus in counts over 787 goldens, and a count
is the wrong instrument for the claim C1 needs. C1 moves one domain to the web:
`app/h/_layout.tsx`, `app/h/[hostId]/index.tsx` and their import closure. A
golden recorded at a call site in there that stopped replaying byte-identically
is paid for by any of the other 684 that started, and every existing assertion
still passes.

`c1-page-closure.ts` names the 22 families and 103 goldens of that closure and
pins each one to the verdict it gives: 54 byte-identical, 49 in four of the five
classes the suite already excludes, all of them recorder observation artifacts
whose wire bytes C0.5 and C0.8 proved identical. Membership is checked per
family, not against the flat id list, so a golden newly derived into a family
this domain owns arrives as a finding instead of being missed for never having
been pinned. A closure golden may only be excluded into a class that carries a
reason in `BRIDGED_PARITY_EXCLUSIONS`.

Two full-corpus assertions go with it. `identical` was a floor; it is now the
exact 787 minus the excluded classes. And the run's own size is pinned to the
corpus: every class is an upper bound, so without that a corpus that lost
goldens outside the identical set satisfied all of them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): move a closure golden to a verdict it is not already pinned to (OTA phase C, C1.6)

The drift test took whichever golden the pin lists first and moved it to
`params-undefined`. Nothing said that golden was not pinned to that class
already, and the day one is, the test asserts a change it never made. Pick the
first golden whose verdict is something else, and assert the pair of verdicts
the line reports rather than just the new one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin `identical` to its baseline, not to the run's own exclusions (OTA phase C, C1.6)

`identical: pinned - excludedCount` took `excludedCount` from the run, and the sum
assertion above it already forces `total(counts) === excludedCount`, so the pair
reduced to `corpus === pinned`: only the size of the corpus was pinned. With every
per-class check an upper bound, a golden moving out of an excluded class into
`identical` satisfied all of them.

`bridgedParityTallyDrift` pins every number to `BRIDGED_PARITY_BASELINE` exactly,
`identical` among them, and its own tests encode the mutation: one
`result-absent-settlement` golden reported `identical` is two drift lines, where
before it was a green run.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): the observation class counts 3, not 7 (OTA phase C, C1.6)

The number in the prose predates the baseline it describes; `result-absent-observation`
is 3 in `BRIDGED_PARITY_BASELINE` and in the run, and the line above it already says
341 / 3 / 6 / 33 / 8.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the checks that are still there, not the bound that is gone (OTA phase C, C1.6)

Three comments described the per-class upper bound the tally replaced. What the
exact pin is exact *against* is now `unclassified`, the exclusion sum and the
membership pins, so say those.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the closure-exclusion check the cheap suite already makes (OTA phase C, C1.6)

`c1PageClosureExclusions()` reads `C1_PAGE_CLOSURE` and nothing the run produced, so
asserting on it inside the three-minute replay bought nothing the cheap suite next
door does not already buy. `c1-page-closure.test.ts` makes the same assertion with a
presence precondition on top, which is the stronger of the two.

The comment beside the tally counted two checks above it; there are three, and a
golden that moved out of an excluded class into `identical` is invisible to all of
them: `result-absent-settlement` is past the nameable cap, so membership drift never
looks at it, and lowering it lowers the exclusion sum with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 19:56:27 -04:00
Jinwoo Hong f24f38bd41 test(mobile): certify the golden corpus through the page bridge (OTA phase C, C0.5) (#21485)
* test(mobile): record the golden corpus through the page bridge (OTA phase C, C0.5)

`ScriptedRpcTransport` gains one seam: an optional `wrapClient`, applied
between the recorder's own instrumentation and the logical client, so a
recording can be driven through another transport without the runner
learning which one. `runRecording` passes it through.

The instrumentation moves outside the seam. `ordinal` orders a logical
`sendRequest` against the recording's device writes and physical payloads,
and the operation makes that call at the same moment with or without a
wrapper; stamping it under one times the wrapper's forwarded send instead,
which the unwrapped recording has no counterpart for. Measured: 131 goldens
move their sender ordinal under the seam, none above it. The name a physical
send is filed by therefore becomes a queue rather than one slot, taken on the
inside of the wrapper — a wrapper that forwards on a microtask arrives after
the next logical call has been made, and one slot hands both sends the second
name. Underflow throws; a wrapper that drops a send is a finding.

`rpc-recording-through-bridge.test.ts` is the harness: every golden recorded
again with `BridgeRpcClient` over an in-memory port pair to a `createBridgeHost`
holding the scripted client, compared body for body against the committed file.
One FIFO per direction, delivery on a microtask, and the `init` handshake
delivered in place before anything mounts.

It is opt-in behind `RPC_FOUNDATION_BRIDGE=1` and does not pass yet. 391 of 787
goldens diverge, for four causes that are findings about the bridge rather than
about the corpus; the suite's header names each one with its count, and flipping
the gate is one line once they close.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the recording corpus and refresh its headers (OTA phase C, C0.5)

The C0.5 seam edits `scripted-rpc-transport.ts` and `run-recording.ts`, both
inside `RECORDER_DIRECTORY`, so `recorderSha256` moves and every golden's header
names an engine that no longer exists. `baseline` moves with it, from
1e3795de99 to ddbb194585, which is main's tip and the tree these were recorded
against; the recorder's fence is clean at that commit, tracked and untracked
alike.

Recorded whole rather than in part, as the README requires: a partial refresh
would leave the corpus pinned to two different trees.

The delta is header-only, and that is checked rather than asserted. All 787
goldens changed exactly two lines each, and across the corpus and the manifest
the only keys that moved are `baseline` and `recorderSha256`, to one value
each. No observation moved, which is the claim the pair of digests exists to
make: the engine changed, what it observes did not.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): correct the bridged-parity counts to what the landed tree measures

The counts in the harness's header were read off an intermediate run: 391 is
the divergence with the reply schema's `_meta` requirement already widened, not
the divergence this tree has. Against the tree as it lands, 763 of 787 goldens
diverge and 24 replay byte-identically; widening `_meta` is worth 372 of them
and takes the count to 391.

The same intermediate run is behind a wrong sentence in this lane's first
commit message, which says no sender ordinal moves with the instrumentation
above the seam. Seven do, plus six payload ordinals, and they are the subscribe
reorder cause 3 now names. 131 was the count below the seam.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repartition the bridged-parity classes from a measured run

The header's last two counts were read off an intermediate measurement. Classified
from the failure text of a run with the `_meta` arms widened, the 391 that remain
partition 345 / 33 / 13, not 340 / 13, and the ordinal class is not a reorder on the
wire: the page posts its frames in call order and they publish in that order, while
the writes the operation makes above the bridge land a delivery earlier.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): let the shared bridge port pair carry any shell client

The golden recorder needs the pair the bridge tests already have, holding its own
scripted client rather than the fake, delivering the handshake in place so a screen
can mount in the same turn, and able to answer one counterfactual: what the page
would have done had the shell posted a field it does not. Every other test keeps the
shape it had under `createFakeBridgePortPair`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): catch a wrapper that swallows a send instead of mislabelling the next

The seam took the next name off the queue without checking it belonged to the method
now on the wire, so a wrapper that rejected without forwarding left its name behind
and the following payload was filed under it. Underflow already threw; this is the
other half, and it is the half that is silent.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the bridged-parity partition by rule instead of by reading it

Counting the classes by eye is how two of them drifted. Every diverging golden is now
named by a rule over the frames and the scenario, each name is disjoint, and a golden
that fits none of them fails the run. The first class needs a second replay with
`_meta` supplied, because nearly every golden is refused some reply for that field
and only supplying it says which ones the field explains: 372 / 338 / 7 / 33 / 13
over 787, with 24 byte-identical. CI runs it so the counts cannot drift again.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the recording corpus and refresh its headers

The seam's name check and the export the parity suite reads both sit inside
`recorderSha256`, and the port pair the suite now borrows sits inside the recorder's
fence, so the pin moves to this branch's tip and all 787 headers follow. Two lines per
golden, both of them header: baseline and recorderSha256, and no body byte anywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the recorder's own boundary ratchets green

Two of them were red, and the first has been since the seam landed, because that
commit was checked against the recorder's directory instead of the whole mobile
suite. The engine may not import a suite, so the parity classifier moves beside the
recorder rather than inside it, where the golden digest would also stop claiming a
recording's provenance for a rule that only reads failures. And the raw-port ceiling
for the scripted transport rises from five to seven: the seam needs one layer between
the operation's call and the logical client, and its two references are what that
layer costs. A named argument type takes back the third.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore(mobile): repin the corpus onto the ratchet fixes and refresh its headers

Supersedes the refresh two commits back: the scripted transport moved again, so both
the digest and the pin do. Two lines per golden, both header, no body byte anywhere.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): stamp the counterfactual on replies only, not on events

An `event` frame carries a `payload` too, so keying off the key rather than
the type put `_meta` into subscription bytes. The page reads an event payload
as `z.unknown()`, so nothing refused it and the classes did not move, but the
tool that names a divergence must not author one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 13:50:55 -04:00
Jinwoo Hong 40b2230508 test(mobile): typecheck the test files on a ratchet, and pin the reply enums where tsc looks (#21298)
* fix(mobile): move the last six reply-enum pins where tsc looks

mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>`
coverage record in a schema test is never typechecked: the two that existed
(SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four
closed enums beside them had only a doc citation of the host type.

Each arm list moves into its schema module as hostUnionArms<Union>(), which
#21269 introduced for the same reason, and each test iterates the exported
list instead of holding its own copy:

- SSH_CONNECTION_STATUS to SshConnectionStatus
- PROJECT_OWNER_TYPE to GitHubProjectOwnerType
- DETAIL_FILE_STATUS to GitHubPRFile['status']
- PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal
  arms of MobilePushTestResult and MobilePushRegisterResult
- SETUP_RUN_POLICIES to SetupRunPolicy

openEnum's parameter widens from a non-empty tuple to `readonly string[]` so
a hostUnionArms list can feed it. z.enum already accepts the same, so the
tuple constraint only excluded callers zod itself takes; behaviour unchanged.

Twelve mutations prove the pins: dropping one arm and adding a bogus one
each fail mobile tsc in all six places. Zero goldens move, the schemas'
behaviour being unchanged, and the 21 recording suites pass at the existing
baseline.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): fix the type errors in eighteen test files

Found by typechecking the tests for the first time (see the config that
follows). All mechanical, none weakens a product type:

- 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils
  where act wants void, so each becomes a block. The async ones await only a
  genuinely promise-returning call, so no extra microtask tick is introduced.
- Four fixtures were stale against a product type that gained a required
  member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError,
  the branch-compare summary's errorMessage, and SessionOptionDescriptor's
  transport, which #20884 added precisely so a producer could not inherit the
  wrong lane's rendering by omission.
- `getLastConnectedAt` on the shared relay fake was typed `() => null`, which
  refused the timestamp two escalation suites assign to it.
- Two holders used before assignment take `!`, one `advance!.kind === ...`
  becomes `advance?.kind`, one widened status arm takes `as const`, and the
  Expo notification fixture keeps `data` required because the dismissal cases
  assign through it.

631 test files pass, 6222 tests, unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): typecheck the test files, on a ratchet

mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the
release bundle, and vitest transpiles without checking types. Nothing had ever
typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written
in one proved nothing and why 144 of the 630 test files had drifted.

tsconfig.test.json is that program with the tests put back, behind
`typecheck:tests`. Four files stay out: they import the desktop main process or
src/shared/child-process, which are written against @types/node, and this
program's libs are React Native's, where setTimeout answers a number rather
than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the
desktop rather than about mobile; vitest runs those four under Node, which is
where they belong.

The CI gate is a ratchet rather than the raw typecheck, modelled on
check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that
set and fails when a file that checks today stops checking, or when a baseline
entry starts checking and was not pruned. The list may only shrink.

Why not zero: 180 of the remaining 510 errors are one seam — tests locate
mocked react-native components by string name, which `ElementType` does not
admit — and closing it means either 180 casts or a global JSX declaration for
the mocked names. That is a design decision, not a mechanical fix, so it is
left for a follow-up rather than made here. The rest are smaller clusters of
the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing,
and createElement props fixtures.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile-recorder): correct the corpus counts and the salvage claim

The oracle section still quoted the corpus as 368 scenarios and 727 goldens;
it is 393 and 778, and the three replay suites report 781 tests. Each number
now names the command that measures it.

"No golden carries one" was the load-bearing error: 44 goldens carry a
recorded `reply-salvage` today, starting with the push-test unknown-reason
scenario #21176 added for exactly that purpose. The paragraph claimed the
observation pins an absence when on those families it pins a recorded drop.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the tests-typecheck ratchet's parser

The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail
under an error. Counting those as filenames would write unparseable entries
into the baseline and leave the gate unprunable, so the parser is pinned on
that shape as well as on the added/stale diff.

Written against the gate itself: it flagged this file before the directive it
carried was removed, which is the end-to-end proof the spawn half works.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): await the timer advances the act() rewrite dropped

Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a
braced body left the returned promise floating at 27 sites, so the advance
was no longer ordered before the assertions that follow it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): unshadow MobileHostCard's .tsx suite

A wildcard `include` keeps only the higher-priority extension, so
MobileHostCard.test.tsx sat outside every tsc program while
MobileHostCard.test.ts existed beside it. Its one error is the same
react-test-renderer seam its sibling is baselined for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census every test file into the typecheck program

The ratchet diffs only files that error, so a test excluded from
tsconfig.test.json or shadowed by a sibling extension left the gate
silently. Every *.test.ts(x) on disk must now be in the program or
named in TESTS_OUTSIDE_PROGRAM with its reason.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(shared): make the enum helpers refuse the ways they can prove nothing

openEnum takes a `const` T so a bare literal keeps its arms rather than
widening to string. hostUnionArms blocks inference of U with NoInfer and
defaults it to never, so a call that omits the host union — where the
record would only pin itself — no longer compiles.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): describe the census and correct the baseline count

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the push fixture cast its SAFETY rationale

Widening the pre-existing cast made the changed-code gate attribute it as
a new finding.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): build the push fixtures as typed notifications

Replaces the `as unknown as` cast with Expo's own types, filling
FirebaseRemoteMessage and its notification once in two builders, and
passes the data payload in rather than mutating through an optional
member. Typing the fixture showed one assertion comparing the scheduled
content against the whole arriving content, which only held while the
cast let the fixture omit the two members the presenter drops; it now
names the four members the presenter forwards.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): keep the grouped-question advance read non-optional

`advance?.kind` let an absent advance take the null-draft branch instead
of failing.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run the tests-typecheck ratchet on Windows

Spawns tsc's JS entry on this Node instead of the node_modules/.bin
shim, which is a POSIX shell script that Windows resolves to tsc.CMD and
then appends .exe to. Parsed paths are normalised to POSIX so a Windows
run does not read every baseline entry as both stale and added.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): close the ratchet's @ts-nocheck hole and read tsc once

tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed"
with one line, pruned, and never checked again; the census now names any
program test file whose leading comment carries the directive.

`--noEmit --listFiles` answers both questions in one pass, so the gate
spawns tsc once rather than twice. Corrects the two stale counts, and
states hostUnionArms' real reason for living in the schema module now
that tests are typechecked.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 18:58:25 -04:00
Brennan Benson abc8386e14 fix(mobile): name a create's launch so a lost reply cannot build two workspaces (#21137)
* fix(mobile): name a create's launch so a lost reply cannot build two workspaces

`agent.launch` admits a caller-supplied `operationId` through a durable ledger, so
exactly one execution happens and every replay returns the recorded answer. No client
sent one, so the machinery was inert and the original defect was still live: mobile
retries a lost create by design, and a retried launch built a second agent in a second
workspace.

Mobile now mints an operation id per create candidate and sends it whenever the host
advertises `agent.launch.replay.v1`.

The invariant is one operation per candidate. `computeAgentLaunchFingerprint` folds
`target` whole, so the workspace name is inside the fingerprint; carrying one id across
a name-collision bump would meet its own row under a differing fingerprint and refuse
`agent_session_operation_conflict`, failing the create outright on the second candidate.
The id is therefore minted beside `clientMutationId` at the top of each loop iteration
and reused verbatim by every retry arm inside that candidate — never re-minted, since a
new id is a new operation.

Admission runs ahead of every effect, so `_invalid` / `_expired` / `_capacity` prove
nothing launched: those re-send the same candidate unnamed rather than let bookkeeping
fail a create the host would have performed. `_unknown` is the one refusal that is not
safe to re-send, and it surfaces.

Also corrects a false comment: the legacy path caches the whole launch under
`clientMutationId`, so inside its 60s window a replay adds neither a workspace nor a
surface, and outside it adds both — not "a second surface, never a second workspace".

* fix(mobile): preserve launch identity on refusals

* fix(mobile): use launch receipts to authorize replay

* test: move mobile launch replay coverage outside node project

* fix(mobile): enforce replay-safe launch delivery at the host

* test: run mobile launch contracts in mobile checks

* test: cover mobile launch contract workflow dependencies
2026-09-17 10:06:11 -07:00
Jinwoo Hong f949d5fcc4 ci(mobile): fail CI when the RPC recording pin leaves main's history or the corpus does not reproduce (#21156)
* test(mobile): fail CI when the RPC recording pin leaves main's history

`mobile/rpc-foundation/pilot-scenarios.json` carries the commit every golden
claims it was recorded from, and `--record` refuses on any other tree. A
behaviour-change branch pins its own last fenced commit, which stops being
reachable the moment the branch squash-merges: nobody can record on main again
until a hand-made repin lands, and until now only a human noticed. #21123 was
that, and so was the repin after #20954.

`scripts/rpc-recording-pin-guard.mts ancestry` fails when the pin is not an
ancestor of the commit under test, and prints the repin recipe. It refuses to
answer on a shallow clone rather than trusting grafted history, so the job
checks out with `fetch-depth: 0`. Ordinary product drift past a reachable pin
is not a failure.

`reproduce` makes the other claim the corpus header makes, which the recording
suites do not: they replay the goldens against the CURRENT tree, so a golden
recorded somewhere other than the pin -- a merge that auto-merged golden JSON,
a refresh copied back from a scratch directory -- passes them and is what the
header exists to deny. It checks the pin out detached, lays this tree's
recorder and manifest over it, and lets the same suites compare in place, so
the comparison is `compareGolden` with lockfile and platform masked as ever.

It runs unconditionally on a push to main, which has no `verify` job and is
where a squash lands a spliced corpus. On a pull request it runs only when the
corpus, the manifest or the recorder moved: nothing else can move the verdict
away from the one the base commit published, and `verify` replays the corpus
against the branch tree meanwhile.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): judge the recording pin against the tree it was read from

Round-1 review of the pin guard.

The pull_request ancestry check read the pin out of the merge preview and judged
it against the branch head. Those differ whenever main repins after the branch
point, so ordinary stale branches failed, and the instruction told the author to
repin to their own head -- which creates the unreachable pin the guard exists to
catch. Judge the checked-out tree instead.

`git worktree prune` in the reproduce teardown was repository-wide. This git
directory is shared by every worktree on the machine (611 registered here), so
it could deregister an unrelated one whose directory was momentarily missing.
`worktree remove --force` alone is enough; a failure to remove is now reported
rather than papered over.

Also: the concurrency group is per commit on main, because GitHub cancels a
pending run in a group whatever `cancel-in-progress` says; the skip gate fails
closed when a provenance path stops matching instead of skipping forever; the
census-boundary comment states the rule the code uses; and five exports with no
consumer are now module-private.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): let an untracked golden and the guard itself buy a reproduction

Two bot findings on the skip gate.

`git diff` sees tracked paths only, but the reproduction's overlay copy and its
census both read the corpus directory as it sits on disk, so an untracked golden
or manifest is input to the verdict and used to skip the run that would judge it.
Enumerate untracked entries under the provenance paths the way the recorder
already does, and run rather than skip: an unjudged local addition is the case
the reproduction exists for.

The guard script is now a provenance path of its own, so a change to it re-runs
the reproduction it implements. Left alone deliberately: run-process.ts and the
workflow's `paths:` scope over src/shared, which is a pre-existing gap for the
whole mobile workflow rather than this job's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): refuse to reproduce when the suite list has drifted from the files

Round-2 review.

The suite names reach vitest as positional filename filters, and vitest exits 0
when only some of them match. A renamed census suite therefore dropped out of the
reproduction silently and the guard still printed that the corpus reproduces:
three files and 761 tests instead of four and 762, exit 0. Resolve every name
under the recorder overlay before spawning, and throw naming the drifted entry.
The unit case walks the list and omits each name in turn, so no single rename can
slip past it. This is the same fail-open shape as the renamed-pathspec finding.

Also: pass an explicit directory type to `symlink`, since Windows needs one and a
junction needs no privilege where a real symlink does; and build the throwaway
test repositories with `symbolic-ref` rather than `--initial-branch`, which needs
git 2.28 against a declared baseline of 2.25.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 02:34:03 -04:00
Brennan BensonandMerge Sim 97aa5ff19b fix(mobile): open native chat when a new worktree launches a default agent (#19850)
* refactor(agent-launch): make the launch-mode decision surface-neutral

`decideWorkerStartMode` was the only shared answer to "structured chat session
or terminal agent?", but it lived in an orchestration-named module and spoke
orchestration's vocabulary, so the other launch surfaces could not call it.
Move the decision to `main/agent-launch/agent-launch-mode` unchanged and leave
`orchestration-worker-start-mode` as the adapter that supplies the noun.

A worker is not a special kind of launch; it is the same launch with a dispatch
attached. Naming the receipt's subject is the only thing orchestration actually
contributed, so that is the only thing the adapter keeps: "worker" in both
sentences, plus the `--terminal` wording, which reads as nonsense anywhere a
`--terminal` flag does not exist. Both are pinned, because they are asserted.

No behavior change. The receipts are byte-identical for every reachable case,
proven by running the new pin against both implementations.

Also pins the wording, which nothing was holding. The existing suites assert
`toContain` fragments ('terminal agent', 'cannot create') and the CLI suite
asserts a receipt handed to it by a mock rather than one this code produced;
all six files stayed green against a deliberately corrupted vocabulary. A
dispatch receipt is the only place a structured-to-terminal downgrade explains
itself, so the whole sentence is the contract, not a fragment of it.

* feat(agent-launch): add the launch intent and the one executor that runs it

The sequencing around the launch decision was duplicated per surface, and the
duplicate is where the bug lives. A new worktree was created agent-first, so
its startup terminal WAS the agent and the structured branch below it could
never be reached — every new-worktree launch was a PTY regardless of the user's
default. Orchestration fixed that for itself in #19431; mobile and the CLI
still have it.

`executeAgentLaunch` inverts the order once, for everyone. When the preference
is structured the worktree is created with NO startup agent, the executing host
is then asked whether it can host a session for the workspace that now exists,
and only then is a surface created. The host verdict cannot be hoisted above
creation: `agentSession.createSupport` only answers for a workspace it can
resolve, which is why the decision stays in two halves.

Agent-first creation is deliberately preserved for PTY launches — it is what
sequences the agent's startup command behind the setup runner, so wait-for-setup
comes for free there.

What actually differs per surface is only how a surface is built (an
orchestration worker's session takes a dispatch hold and a mailbox a plain
launch must not take), so that is injected as a factory rather than branched on.

The intent also strips the reserved agent fields from a migrated create payload:
a caller moving off `worktree.create` passes its existing params, and a stale
`startupAgent` in there would re-create the very path this replaces.

Tests assert order and arguments, not just the resulting mode. Reintroducing
agent-first creation reddens 4 of 11.

* feat(agent-launch): expose the launch executor as the agent.launch RPC

Adds `agent.launch` — one host-side method that decides structured-vs-terminal and
creates the surface — wired to the real runtime factories: `createManagedWorktree`
for the workspace, forking on `startupAgent` exactly as the orchestration worker
path does; `createStructuredAgentSessionForWorktree` for a chat session; and
`createTerminal` for a PTY agent. Allowlisted for mobile, which is the surface the
routing gap was reported on.

`worktree.create` is untouched. Its `startupAgent` keeps meaning "spawn a PTY agent"
verbatim, because it answers with `agentTerminalHandle` only on that path: a host
that quietly routed it to a structured session would hand every older client a
response with no handle and no error. All new behaviour sits behind
`agent.launch.v1`, which the host now advertises and a remote client must negotiate,
so a client that does not gets today's behaviour unchanged.

* feat(mobile): route workspace creates through agent.launch

Picking an agent on the mobile create sheet always produced a terminal, even
when the user's default was native chat, because all three create paths put
`startupAgent` on `worktree.create`. That means "create the worktree
agent-first", so its startup terminal IS the agent and the structured branch
below it is unreachable — while the same phone's in-workspace "+" button opened
a chat.

The blank, branch and new-branch creates now send the same payload through
`agent.launch` and let the host settle the surface. `worktree.create` is
untouched, and a host that does not advertise `agent.launch.v1` (read from the
existing `status.get` probe) keeps today's path exactly.

Work-item creates stay on `worktree.create`: they pre-fill the issue/PR URL as
an unsent `startupDraft`, which a structured session cannot hold yet, so routing
them would submit the URL as a first turn.

* fix(agent-launch): drop the deleted draft-prompt blocker from the reason map

main removed the draft-prompt blocker in #19681 (a structured session now holds
an unsent draft), so the exhaustive Record no longer typechecks.

* chore(agent-launch): carry a SAFETY rationale on the agent placement cast

The type-assertion gate landed after this branch's base, so the new file's
copy of the worker-start cast is now a changed-code finding.

* chore(agent-launch): carry agent.launch through main's RPC typing and casting gates

The typed-method contract, the generated params catalog and the
`assertionStyle: never` casting scan all landed after this branch's base.

- AGENT_LAUNCH_METHODS kept an `RpcMethod[]` annotation, which widened its
  method name to `string` and broke assignability; every sibling infers instead.
- `agent.launch` binds a schema under src/main, so it joins the catalog's
  RPC_METHODS_WITHOUT_SHARED_PARAMS and the parity gate's hand-listed twin.
- The now-typed methods make most test casts unnecessary; the few that remain
  carry the line-specific SAFETY rationale the casting gate requires.

* test(mobile): supply the agent-launch fixture the create-submit recording needs

The golden RPC recordings landed upstream while this branch was out, so they
first met agent.launch here. Three things had to happen, and only one of them is
a fixture bump.

1. workspace-settings-mounts.ts mounts useNewWorkspaceCreateSubmit against a
   fixture model that throws on any member it was not given. This PR added a
   required getAgentLaunchSupport, so the submit aborted with "Missing model
   fixture" before it ever issued the create, and three cleanup checkpoints
   vanished. That read like a product regression and was not one. Supplying the
   member restores the recording byte-for-byte; it is pinned false for the same
   reason the cutover probe is, so the baseline stays on worktree.create.

2. Editing that adapter moves adapterSha256 for the twelve settings goldens it
   mounts. Their recordings are unchanged - header only, by design: the digest
   is per-golden so editing a module fails exactly the goldens that mounted it.

3. Five goldens changed behaviourally, and both changes are this PR's:
   the capability probe now reports agentLaunch, and a create whose reply
   carries no worktree returns "Failed to create workspace" instead of throwing
   a TypeError off an unguarded result.worktree read. The launch route needs
   that guard, since a receipt can arrive without a worktreeId.

* refactor(mobile): decode the launch receipt instead of asserting its shape

The changed-code quality gate refuses type assertions, and the eight it flagged
were worth removing rather than suppressing.

The production one was the point. readAgentLaunchCreateOutcome asserted the RPC
payload into Partial<AgentLaunchResult> and then runtime-checked it anyway, so
the assertion bought nothing and claimed a contract the host had not proven. It
now narrows with `in` and validates each hop, which is the same nullability
question readCreateResult already answers on the sibling path - a launch receipt
can legitimately arrive without a worktreeId. AgentLaunchCreateOutcome ties
worktreeId to the shared contract so a change there fails this reader's
typecheck rather than passing a differently-typed field through.

The test fakes claimed a whole RpcClient via `as unknown as RpcClient` while
implementing one member. They now build a typed literal, matching the pattern in
use-mobile-structured-agent-options.test.ts. The read sites cast params and then
read one field; they now assert the payload with toMatchObject, which removes
the cast and pins more of the shape than the cast did.

Also pins the warning passthrough, which nothing covered: a terminal launch that
seats the workspace but cannot start the pty reports why, and the absent, blank,
non-string and structured-surface cases report nothing. Writing that test caught
a real drop I had introduced in the reader.

* ci(mobile): re-run Mobile Checks when a shared capability changes

Mobile Checks is path-filtered to mobile/**, but mobile imports the negotiated
capability names straight from src/shared/protocol-version.ts and records the
whole capability read verbatim in its goldens. So a capability added desktop-side
rewrites a mobile fixture while never triggering the suite that would catch it.

That is what happened here: #19849 introduced agent.launch.v1 and Mobile Checks
never ran on it. Verified at the run level rather than by check name - the
window-free check-runs API on 3837ae8d51 returns 49 check-runs across six runs
(PR Checks x2, PR test LoC x2, Track Community PRs, Review) and no Mobile Checks
among them. The breakage surfaced only in this PR, which happens to touch mobile/**.

The workflow already concedes this pattern for terminal-file-link-conformance.ts;
protocol-version.ts has the stronger claim, since mobile records its output.

Also corrects the mount adapter's SAFETY comment. It claimed the recorder supplies
only the members the hook reads, which was false the moment the hook gained a
required getAgentLaunchSupport - and the assertion it annotates is exactly what
stopped the compiler from saying so. The twelve goldens are adapterSha256 churn
from that comment: every body is byte-identical, which is the digest doing its job.

* docs(agent-launch): stop the receipt-wording comment claiming a migration

The decision was never moved out of orchestration-worker-start-mode; this PR
adds a second copy beside it. Say so, and name the unenforced agreement.

* docs(agent-launch): stop the executor comment claiming a migration that has not happened

The header asserted two things the tree does not support: that every launch
surface routes through the executor, and that the mode decision "already lived"
in `agent-launch-mode`. `agent.launch` is the executor's only consumer, and
`orchestration-worker-start-mode.ts` is byte-identical (blob 92dc5c644a, 217
lines) at the merge base and all three stack heads, still used by workers.ts.
Describe the two live copies and leave the cutover to later stack work.

* fix(agent-launch): preserve setup and refusal fallbacks

* refactor(mobile): parse the launch outcome into a named type at its boundary

anti-slop/no-object-parameters flagged terminalLaunchWarning's `result: object`.
The rule is pointing at a real seam rather than a style nit: the helper advertised
a loose object and did the narrowing inside itself, so every caller handed it
unparsed wire data and nothing downstream held a real type.

Parsed at the boundary instead. parseTerminalLaunchOutcome takes `unknown` and
returns TerminalLaunchOutcome | null, so the narrowing happens once, where the
untrusted payload enters, and the consumer works with a named type.

The type is taken from the shared contract rather than restated - a Pick over the
terminal member of AgentLaunchOutcome - so a change to that union fails here
instead of flowing through. `handle` is deliberately excluded: nothing reads it,
and requiring it would drop the warning off a reply that omitted one, which is a
behaviour change smuggled in under a typing change.

No assertion and no config exemption: reintroducing `as Partial<AgentLaunchResult>`
would trade this finding for the defect removed earlier in this branch, and the
rule is correct here.

The rule arrived with the merge-forward (#20781, newer than this branch's
merge-base), and anti-slop is not one of the changed-code gate's six scans - it
runs only repo-wide - which is why a clean local gate did not predict it.

Behaviour is unchanged across all five warning cases, and the positive case was
re-ablated on the new parser: dropping the warning reddens exactly it,
1 failed | 18 passed, restored byte-identical to 19 passed.

* fix(agent-launch): dedupe complete launch and cancel setup wait

* fix(agent-launch): memoize the whole launch so a replay cannot mint a second session

A replayed agent.launch could create a second structured session in the same
worktree, with activate: true.

dedupeWorktreeCreate wrapped only the worktree half, inside the workspace
factory. On a replay the create was reused, and the executor then continued to
createSurface and built another surface inside it. The terminal route hid this:
its cached create carries a startup terminal handle, so the executor returns on
early. A structured create has no handle by construction - that is the whole
point of the structured fork - so it fell through every time. Mobile replays
this method deliberately on a delivery-ambiguous response, up to five attempts,
so the path is reachable by design rather than in theory.

The handler now wraps the entire launch in the same dedupe, on the same
(repo, clientMutationId) identity, exactly as worktree.create wraps its own
body. A replay returns the original AgentLaunchResult instead of re-running
createSurface, which makes the two routes replay-identical.

The inner dedupe is removed rather than kept. Wrapping both levels on one key
deadlocks: dedupeWorktreeCreate stores the in-flight promise before the inner
call runs, so the inner call would be handed the outer's promise, which is
waiting on it. The launch-level memo subsumes the worktree-level one.

Failures are still dropped rather than cached, so an unknown outcome stays
unknown instead of replaying as a fabricated success.

The guard replays a STRUCTURED launch: the terminal route cannot reproduce this
and a test there would pass either way. Ablated against the pre-fix files -
1 failed | 22 passed, "expected vi.fn() to be called 1 times, but got 2 times",
which is the duplicate session - then restored to 23 passed. The stub's dedupe
had to be made faithful for that to be observable; the shared one passes through
so other tests can see raw calls.

* Revert "fix(agent-launch): memoize the whole launch so a replay cannot mint a second session"

This reverts commit 59bc5e9b04.

The same defect was already fixed upstream on this stack's base branch by
539e283c0f, which landed while this was being written. That change is broader
(it also cancels the setup wait) and namespaces the dedupe key, so it supersedes
this one. Reverting rather than hand-merging keeps a single implementation
instead of a hybrid nobody chose.

The behavioural guard from this commit is ported back on top of the upstream
implementation separately: it asserts exactly one structured session survives a
replay, where the upstream tests assert the dedupe wiring.

* ci(mobile): close the round-1 signal gaps around agent.launch

Three review findings, all narrow.

Mobile Checks is path-filtered, and this branch made mobile's types depend on the
shared RPC contract: rpc-params-contract.ts is a type-only re-export of the
generated params catalog, and mobile/tsconfig.json includes **/*.ts. So a
desktop-only edit under src/shared/rpc-contract/ could break mobile's typecheck
with no mobile signal at all - the same blind spot the protocol-version.ts entry
closed, one directory over. Added src/shared/rpc-contract/** to the paths filter.

agent.launch had no cross-version trigger. Added the three prefixes a paired peer
actually exchanges: the intent contract, the wire schema, and the RPC method.
src/main/agent-launch/ is deliberately NOT listed - the executor shapes behaviour
but is not itself wire, and AgentLaunchResult's shape is already covered by
agent-launch-intent. Extending the cross-version SUITE to cover a negotiated
handshake is separate work, not this.

The break branch that answers an accepted-but-empty reply with "Failed to create
workspace" had no unit coverage; the golden that used to discriminate it
collapsed five partitions into one shared error when the null guard replaced the
unchecked read. Covered on BOTH routes - worktree.create with no worktree.id and
agent.launch with no worktreeId - since the branch serves both. Ablated by
bypassing the guard: 2 failed | 11 passed, the two new cases returning a
fabricated worktree instead of the error, restored to 13 passed.

* fix(agent-launch): give a launch one place to say the workspace is incomplete

createManagedWorktree reports an unspawned startup terminal or an uncopied
working tree as a top-level `warning`, and worktree.create hands it straight to
mobile. The launch path narrowed that result down to
{worktreeId, startupTerminalHandle} and dropped it, so every agent.launch create
lost a warning the old method surfaces - on both arms.

The channel was also asymmetric by accident rather than design: a terminal
outcome could carry `warning`, a structured one had nowhere to put it, so the
arm this PR exists to enable was the arm that could not report an incomplete
create at all.

Now there is exactly one place a launch warning lives: AgentLaunchResult.warning,
at the top level. It is about the create as often as the surface, it applies to a
structured session and a terminal alike, and a reader should not branch on
outcome.kind to discover the workspace it just opened is missing something. The
terminal arm's own `warning?` is removed rather than left beside it - two homes
for one fact is how they drift. Every producer folds in: the create, the surface,
and the refusal downgrade.

Consumer census before removing it: one production reader (mobile's
readAgentLaunchCreateOutcome) and no others - the renderer and mobile launch
call sites never read it. The mobile reader now reads the top-level field, which
also lets its outcome parser go away entirely.

Guard ablated by restoring the pre-fix narrowing: 2 failed | 24 passed, both
carriers reporting `expected undefined`, which is the dropped warning itself;
restored to 26 passed. The third case asserts an absence and stays green under
the mutation by construction - it pins shape, not the defect.

* fix(agent-launch): combine both launch warnings instead of dropping one

Round 2 found the comment here was false. A create warning and a surface warning
CAN both be set, on two reachable paths:

  1. The create warns precisely BECAUSE it produced no startup terminal -
     didSpawnStartup stays false when that spawn throws, and
     orca-runtime-create-managed-worktree.ts:283 gates startupTerminal on it - so
     the executor's early return is skipped and a second surface is built, which
     can warn too.
  2. An untracked-copy warning, then a definitive structured refusal downgrading
     to a terminal that also warns.

`??` kept the first and lost the second with nothing saying so. They are now
combined the way the create combines its own failures - appendFailure in
runtime-local-worktree-terminal-startup.ts, and the startup-terminal catch in
runtime-remote-managed-worktree-create.ts - which append rather than replace.

The comment is rewritten to say what is true, and records the gap NOT fixed
here: a create warning about a failed startup terminal is stale once the launch
recovers by building a working one, so a user can be told the agent did not start
while looking at it. Distinguishing those needs createManagedWorktree to stop
multiplexing two unrelated failures into one string.

Guarded and ablated: restoring `??` reddens exactly the new test, with the
surface clause missing from the received string; restored to 27 passed. The
structured-create stub had to admit its real ok-or-refusal union for the
downgrade path to be modellable at all - it previously declared only the ok arm.

Also: mobile.yml gains src/shared/agent-launch-intent.ts. It is the sole holder
of the agent.launch RESULT shape - the rpc-contract catalog holds params only -
and mobile imports it as a value. CROSS_VERSION_WIRE_PREFIXES already treats it
as wire-critical; without this, one gate does and the other cannot see it.

And the agent-first warning test no longer pairs "startup terminal failed" with a
returned handle, a combination the producer cannot emit.

* fix(mobile): read a launch warning an older host nests on the outcome

agent.launch moved `warning` from the terminal outcome to the top level of the
result. That is the right shape - a reader should not branch on `outcome.kind`
to learn the workspace it just opened is incomplete - but on the wire it is a
REMOVAL, and mobile only read the new place.

A host built before the move still advertises the same `agent.launch.v1`
capability, so the capability probe cannot tell the two apart and mobile takes
this route against one:

  protocol-version.ts:360       AGENT_LAUNCH_RUNTIME_CAPABILITY is in
                                RUNTIME_CAPABILITIES, the host list
  orca-runtime-get-status.ts:64 publishes it via status.get; the filter drops
                                only browser.screencast.v1 and three E2E-gated
                                capabilities, never agent.launch
  agent-launch-executor.ts      such a host writes warning INSIDE outcome

The result was a regression rather than a contract cleanup: the worktree.create
path this replaces returned the warning at the top level and mobile read it, so
a create that seated the workspace but could not start the agent surface - pty
exhaustion, untracked files not copied - stopped explaining itself on the phone.

Read both shapes for as long as such a host can be paired. Top level wins, and
cannot be shadowed: AgentLaunchOutcome has no `warning` on either arm, so a
current host cannot nest one.

The test that pinned the old behaviour is inverted here. Its comment was the
actual defect - it framed a legitimate warning from an older peer as a stale
shape to defend against, which is what made dropping it look deliberate.

* chore(mobile): raise the unchecked-reader ceiling for the agent.launch receipt

main landed `unchecked-rpc-reader-inventory.ts`, a ratchet on RpcOperation
readers that re-type their reply instead of validating it. Its ceiling for
mobile-workspace-create-operations.ts is 4, counted on a tree without this
branch's `agentLaunchRun`, so the merge produced "listed 4, found 5".

The inventory's own header prescribes this case: a merge is the one time a line
goes up without a migration undoing itself, and the instruction is to raise it
and name the PR that brought it. It describes main landing an operation the
branch never saw; here it is the mirror - the branch holds one main had not
seen - so the line is annotated with #19850 rather than left bare.

Not converted to `rpcResultVariant(variant, schema)`, which would lower the line
instead. That is a validation change rather than a migration, which is exactly
what the file's own comment says these five readers deliberately are not; the
agent.launch reply is already guarded at the consumer, where
readAgentLaunchCreateOutcome returns null on a malformed payload and the create
surfaces "Failed to create workspace". Writing a schema now would also target a
reply shape #20999 is actively redefining.

Ablated: with the line back at 4 the ratchet fails "listed 4, found 5"; at 5 it
passes.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-16 13:15:20 -07:00
Neil 4aa9329e99 ci: narrow pnpm cache keys and shallow development checkouts (#20370)
* ci: cache dependency downloads and shallow development checkouts

* ci: defer mobile caches after measuring restore overhead

* ci: retain existing release signing cache behavior
2026-09-12 01:31:04 -07:00
Jinwoo Hong e187c82678 Revert mobile push rollout pending delivery investigation (#20040) 2026-09-11 02:17:58 -04:00
Jinwoo Hong d33354cfd2 feat(mobile): receive native push notifications from paired desktops (#19951)
* feat(mobile): deliver native push notifications from paired desktops

* fix(mobile): retry push capability probes

* fix(mobile): cancel retired push capability probes

* fix(mobile): ignore stale push reconciliations

* fix(mobile): type capability probe at its boundary

* fix(notifications): route mobile push taps to the originating pane

* Require explicit mobile push-service consent on upgrade
2026-09-11 01:00:16 -04:00
Neil 7bb54cc2f7 ci: reduce runner overhead and disposable package compression (#18948)
* ci: reduce PR runner overhead and package compression time

* ci: validate mobile when its dependency action changes
2026-09-05 16:56:57 -07:00
Neil b17f60d744 build: upgrade to pnpm 12 (#17156) 2026-08-29 14:13:26 -07:00
Neil 51ed7d4f67 Pin pnpm and rebalance scheduled E2E (#17133)
* Pin pnpm and rebalance scheduled E2E

* Give scheduled E2E failure headroom
2026-08-29 03:13:46 -07:00
Brennan Benson 7b4e10b104 fix(mobile-ios): pin fastlane and gate the Fastfile in CI (#15092)
* fix(mobile-ios): pin fastlane and gate the Fastfile in CI

The ios-distribute job failed on every run from 2026-08-10 to 2026-08-13
because distribute_testflight passed distribute_only without app_platform,
so pilot fell through to an interactive platform prompt on ubuntu. No CI
check loads the Fastfile, so external testers got nothing for six days.

- Pin fastlane 2.238.0 and commit mobile/Gemfile.lock so ios-build (macos)
  and ios-distribute (ubuntu) cannot resolve different versions ~25 minutes
  apart. Fixes the Gemfile comment's dead mobile-build.yml reference.
- Add a Fastfile smoke check (bundle exec fastlane lanes) plus a static
  contract test for the TestFlight lane arguments to Mobile Checks.
- Set reject_build_waiting_for_review so a superseded same-train build in
  beta review stops blocking the submission.

* fix(mobile-ios): install the pinned Gemfile.lock in frozen mode

Without frozen, a lockfile that drifts from the Gemfile is silently
re-resolved per job, which is the version split the pin exists to prevent.

* test(mobile-ios): anchor the TestFlight argument contract against an empty selection

* chore(mobile-ios): canonicalize the lockfile platforms

Bundler's own normalization drops arm64-darwin-25 as redundant with the
versionless arm64-darwin, and the ubuntu runners resolve x86_64-linux-gnu.
2026-08-17 12:03:20 -07:00
OrcaWinandOrcaWin 801ff57e83 fix(mobile): unblock iOS releases (#10224)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-23 14:23:44 -07:00
Jinwoo HongandOrca 77b154d5dd Add Orca Relay desktop and mobile transport (#8536)
* feat(mobile): define relay protocol groundwork

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): implement replay-safe E2EE v2 sessions

Co-authored-by: Orca <help@stably.ai>

* test(auth): lock cloud refresh single-flight

Co-authored-by: Orca <help@stably.ai>

* test(mobile): complete E2EE v2 adversarial coverage

Co-authored-by: Orca <help@stably.ai>

* refactor(runtime): unify mobile socket wiring

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay control and data clients

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): coordinate desktop relay sessions

Co-authored-by: Orca <help@stably.ai>

* fix(auth): fence stale cloud session mutations

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay pairing and durable revoke

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay credential pairing RPCs

Co-authored-by: Orca <help@stably.ai>

* feat(settings): show Orca Relay sign-in status

Co-authored-by: Orca <help@stably.ai>

* test(relay): prove desktop lifecycle and E2EE splice

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): persist relay pairing state

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): race direct and relay pairing

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): recover pairing through relay director

Co-authored-by: Orca <help@stably.ai>

* fix(relay): preserve origin controls during drain

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): recover interrupted relay pairing

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): add stable relay RPC sessions

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): supervise direct and relay endpoints

Co-authored-by: Orca <help@stably.ai>

* Cover mobile relay director fallback matrix

Co-authored-by: Orca <help@stably.ai>

* Fix relay settings component test isolation

Co-authored-by: Orca <help@stably.ai>

* Remove unrelated merge formatting drift

Co-authored-by: Orca <help@stably.ai>

* Update runtime connection count integration assertion

Co-authored-by: Orca <help@stably.ai>

* Run mobile typecheck through pnpm

Co-authored-by: Orca <help@stably.ai>

* feat(relay): gate desktop controls on mobile demand

Co-authored-by: Orca <help@stably.ai>

* test(mobile): cover served relay recovery

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): upgrade direct pairings to relay

Co-authored-by: Orca <help@stably.ai>

* fix(relay): harden mobile reconnect and teardown

Co-authored-by: Orca <help@stably.ai>

* fix(auth): clarify account sign-in state

Co-authored-by: Orca <help@stably.ai>

* fix(auth): polish sign-in completion flow

Co-authored-by: Orca <help@stably.ai>

* fix(auth): clarify sign-out confirmation

Co-authored-by: Orca <help@stably.ai>

* fix(auth): simplify sign-in completion page

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): add per-device pairing connection mode

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): stabilize pairing option layout

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): give pairing choices stable space

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): stabilize pairing QR regeneration

Co-authored-by: Orca <help@stably.ai>

* Animate mobile pairing flow height

Co-authored-by: Orca <help@stably.ai>

* Configure auth in packaged builds

Co-authored-by: Orca <help@stably.ai>

* Make Orca Relay pairing an opt-in beta

Co-authored-by: Orca <help@stably.ai>

* Show Relay beta details on hover

Co-authored-by: Orca <help@stably.ai>

* Refine mobile relay pairing choice

Co-authored-by: Orca <help@stably.ai>

* Polish Orca Relay pairing controls

Co-authored-by: Orca <help@stably.ai>

* Keep mobile contract fallback test additive

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-14 11:47:05 -07:00
Brennan BensonandOrca c06507ee30 Open terminal file links on mobile (#7134)
Co-authored-by: Orca <help@stably.ai>
2026-07-02 18:17:47 -07:00
Jinwoo Hong 6740c00351 Build and ship iOS to TestFlight from CI (#5468)
Adds an ios-build job to the Mobile Release workflow (build + sign + TestFlight upload on a macOS runner via fastlane + App Store Connect API key), and fixes a pre-existing Mobile Checks bug where the mobile typecheck needed root deps for ../src imports.

Merged with --admin: the only failing check is the repo-wide pr.yml '@/lib/utils' vitest resolution break affecting all open PRs, unrelated to this change.
2026-06-15 21:38:00 -07:00
Neil 6f60541130 Make mobile lint inherit root policy
Squashed from PR #4353.
2026-05-31 21:58:17 -07:00
Neil e53300a4be ci: upgrade GitHub Actions to Node 24 (#2978) 2026-05-28 00:07:04 -07:00
Jinwoo HongandOrca 7944293815 Improve mobile terminal streaming performance (#1700)
* Improve mobile terminal streaming performance

Co-authored-by: Orca <help@stably.ai>

* Add mobile clear terminal action

Co-authored-by: Orca <help@stably.ai>

* Fix terminal connection test mock

Co-authored-by: Orca <help@stably.ai>

* WIP: mobile markdown tabs before rebase

Co-authored-by: Orca <help@stably.ai>

* Add mobile markdown editing

Co-authored-by: Orca <help@stably.ai>

* Harden mobile tab and markdown sync

Co-authored-by: Orca <help@stably.ai>

* Fix mobile terminal reconnect loading race

Co-authored-by: Orca <help@stably.ai>

* Polish mobile terminal keyboard behavior

Co-authored-by: Orca <help@stably.ai>

* Simplify mobile markdown editor chrome

Co-authored-by: Orca <help@stably.ai>

* Move mobile markdown actions to top

Co-authored-by: Orca <help@stably.ai>

* Use app modals for markdown discard

Co-authored-by: Orca <help@stably.ai>

* Dismiss keyboard before markdown confirmations

Co-authored-by: Orca <help@stably.ai>

* Add mobile file explorer

Co-authored-by: Orca <help@stably.ai>

* Fix mobile file explorer type narrowing

Co-authored-by: Orca <help@stably.ai>

* Fix mobile files navigation param

Co-authored-by: Orca <help@stably.ai>

* Show mobile files connection wait state

Co-authored-by: Orca <help@stably.ai>

* Preview text files on mobile

Co-authored-by: Orca <help@stably.ai>

* Simplify mobile file previews

Co-authored-by: Orca <help@stably.ai>

* Clarify unavailable mobile file types

Co-authored-by: Orca <help@stably.ai>

* Fix mobile subscription and preview review issues

Co-authored-by: Orca <help@stably.ai>

* Keep fallback terminals visible on mobile

Co-authored-by: Orca <help@stably.ai>

* Keep mobile terminal tap active

Co-authored-by: Orca <help@stably.ai>

* Preserve mobile terminal fallback order

Co-authored-by: Orca <help@stably.ai>

* Fix mobile session tab authority

Co-authored-by: Orca <help@stably.ai>

* Run mobile tests in mobile CI lane

Co-authored-by: Orca <help@stably.ai>

* Bump mobile app version to 0.0.7

Co-authored-by: Orca <help@stably.ai>

* Allow main window IPC wiring size

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-05-12 14:38:12 -07:00
Jinwoo HongandOrca fc578f5ea9 feat(mobile): Expo companion app [beta] (#1245)
Co-authored-by: Orca <help@stably.ai>
2026-05-04 13:08:27 -07:00