mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
stack-final
11435
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
3bb3e4331d | test(editor): cover saved preview and destination repairs | ||
|
|
0909ef3966 |
Merge branch 'stack-reconcile' into stack-final
# Conflicts: # src/renderer/src/components/editor/rich-markdown-extensions.ts # src/renderer/src/components/editor/rich-markdown-literal-serialization.ts |
||
|
|
21a68a56f9 | fix(editor): scope escape encoding to text nodes | ||
|
|
4e82de412a |
Merge branch 'stack-preview' into stack-reconcile
# Conflicts: # src/renderer/src/components/editor/editor-panel-render-model.ts # src/renderer/src/components/editor/rich-markdown-inline-math.ts # src/renderer/src/components/editor/rich-markdown-literal-serialization.ts |
||
|
|
6afe452eb0 | fix(editor): protect fence info and unsaved preview boundaries | ||
|
|
dbf0cc27b5 | Merge branch 'stack-structure' into stack-preview | ||
|
|
6ae6252e0d | Merge branch 'nwparker/markdown-better' into stack-structure | ||
|
|
129807f795 | Merge branch 'nwparker/markdown-scanners' into nwparker/markdown-better | ||
|
|
24cbbd6c2b | refactor(editor): add shared Markdown scanners | ||
|
|
a9d6e3a5bb | fix(editor): support CJK emphasis in preview | ||
|
|
3132c48fdc | fix(editor): preserve code contents and validate reference candidates | ||
|
|
9ed7b45fe7 | fix(editor): harden Markdown scanners and fixtures | ||
|
|
09073086a8 |
feat(terminal): inline images via @xterm/addon-image (perf-first) (#19512)
* feat(terminal): inline images via @xterm/addon-image, perf-first Add opt-in inline terminal images (SIXEL, iTerm2 IIP, Kitty graphics) through @xterm/addon-image, designed to keep idle terminals unaffected. Performance: - The addon (base64-inlined wasm decoders + protocol handlers) loads off the boot critical path via a deferred loader that mirrors the WebGL addon: primed after first paint only when the setting is on, read back synchronously at attach, with a 3-attempt cap so a transient failure never disables images for the session and a missing chunk never refetches per pane. renderer-boot-graph guards against eager import. - enableSizeReports:false so the addon never sets windowOptions and double-answers Orca's own CSI 14t/16t responder. - Perf-tuned decode/storage limits (storageLimit, sixel/iip/kitty size caps) in one place. Correctness: - Orca's DA1 handler wins over the addon's (last-registered-first), and the default DA1 response never advertised Sixel (;4), so DA1-detecting tools (chafa, img2sixel, viu, timg) never emitted it. The winning handler now appends ;4 while the setting is on, resolved per query so a live toggle changes the next DA1; idempotent against the ConPTY response that already lists it. - ORCA_IMAGE_PROTOCOL=kitty is exported to spawned shells (local, daemon, relay/SSH) and forwarded across the WSL boundary, so image-capable agents can pick an encoder. Unknown image sequences are swallowed by xterm when the addon is detached, so this never garbles output. - Settings toggle (default on) gates rendering and DA1 advertisement. Cross-checked against community PRs #7775, #11706, and #19201 at the end; credited below. Co-authored-by: s546126 <s546126@users.noreply.github.com> Co-authored-by: XRX193 <XRX193@users.noreply.github.com> Co-authored-by: lmsh7 <lmsh7@users.noreply.github.com> * fix(terminal): bound inline image memory and classify Kitty replies * fix(terminal): bound image decode and release image resources on cleanup * fix(terminal): address image addon review feedback * test(terminal): stub setPaneInlineImagesEnabled in appearance manager fakes * fix(terminal): evict unplaced kitty payloads before displayed images Byte-budget eviction dropped the oldest transmitted blob regardless of placement, so a new upload could erase a visible image while abandoned blobs still held budget. Unplaced payloads now go first and displayed ones only when that is not enough. The incoming image is always stored, so an oversized one overshoots the cap by one payload instead of being dropped after the protocol already acked OK. * fix(terminal): gate DA1 Sixel on real addon attachment; claim SSH image spec in CI - DA1 advertised Sixel from the setting alone, so a pane whose lazy addon chunk was still loading (or had failed all three attempts) told feature-detecting tools to emit DCS that nothing could render. Track the attached decoder per terminal and require it before setting the ;4 bit. - tests/e2e/terminal-inline-images-ssh.spec.ts was Docker-gated but claimed by no lane runner, so pr-e2e-gate-contract failed and the spec would have self-skipped green forever. - Reject non-positive PNG IHDR dimensions before decode: they are parsed with signed shifts, so a dimension >= 0x80000000 came back negative and slipped past the pixel-limit comparison. - One resolveTerminalInlineImagesEnabled() for the default-on setting; the four call sites mixed '?? true' with '!== false', which disagree on null. - One readInlineImageResources() walk of the addon internals instead of two copies that could drift against the patched dependency. - Isolate the deferred-attach drain per pane; make the zoom-invariance and backing-storage e2e assertions fail when the feature is dead. * refactor(terminal): one lazy xterm addon loader for webgl and image terminal-image-addon-loader was a structural clone of the webgl one — same memo, attempt cap, and .then(ok,err)-clears-memo recovery. Both now wrap createLazyXtermAddonLoader; each keeps its literal import() specifier so the bundler still splits the chunk (verified against a fresh build: addon-image stays out of the boot graph). * refactor(terminal): name openTerminal's addon flags; pin image addon limits Two adjacent optional booleans could be swapped without a type error once inline images added the second one. * docs(terminal): state the real per-pane image ceiling; drop test ordering dependency storageLimit:32 reads like the pane's budget but keys three pools — decoded pixels, retained encoded Kitty blobs, and pending WASM decoders — so the worst case is ~98 MB per pane with no cross-pane governor. Say so at the constant. pane-inline-images.test.ts's deferred case needed to run first; it now takes a fresh module instead, and the rest prime in beforeAll. Verified by running the file with that test moved last. * fix(terminal): satisfy rebased static analysis gate * fix(terminal): complete casting gate cleanup * fix(terminal): recover failed image addon loads * fix(terminal): bound image decoder allocations --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: s546126 <s546126@users.noreply.github.com> Co-authored-by: XRX193 <XRX193@users.noreply.github.com> Co-authored-by: lmsh7 <lmsh7@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai> |
||
|
|
9ebfd2e8ae |
Reapply "feat(composer): choose a base ref in the New Workspace composer" (#21536) (#21543)
This reverts commit
|
||
|
|
a7b9ea5995 | Revert "feat(composer): choose a base ref in the New Workspace composer" (#21536) | ||
|
|
164c7140fd |
fix(relay): stop reporting an unavailable home cell as exhausted capacity (#21518)
A host whose home cell is not live — readiness false, drained, or inside a boot window — is refused by the committed-fence branch in assignOnce() without any capacity being consulted. It answered relay_capacity_exhausted, so every cell boot and every readiness dip printed capacity rejections at 17% fleet utilisation and sent an investigation after headroom that was never short. The branch now raises RelayHomeCellUnavailableError, which carries the cell id and which of cellIsLive()'s conditions failed (draining / booting / unheard / not_ready). The director logs reason, cause and cell, and returns the new reason in the same retryable 503. Nothing on the wire reads the body: the desktop client discards it unread and branches on status only, and no log-based metric or alert parses the reason. The load harness, the only body-reading consumer, gets its own bucket so a home-cell rejection no longer inflates the capacity count. Hinted grants are now logged on whichever lane served them, so a host that failed sticky verification and was rehomed by placement leaves a record of where it landed. Unhinted placement grants stay silent. |
||
|
|
3467e5f6b5 |
fix(relay): check the rehome dispatch budget before planning the candidate join (#21517)
* fix(relay): check the rehome dispatch budget before planning the candidate join `selectIdleRegionalRehomeCandidates` read the enable control and the fleet safety snapshot, then ran the twenty-table candidate join, then handed every row to the worker, which POSTed each one to its source cell. Only there — in `commitIdleRegionalRehome`, three statements into a write transaction that takes `FOR UPDATE` on two global single-row tables — was the durable dispatch budget consulted. The budget is ten moves a minute (`next_dispatch_at = now + 6s`), and five directors poll every six seconds, so most of that work was spent to be told the budget was closed. A five-minute `paused_until` made every poll in the window do it. The gate is a single-row primary-key read, so it goes in front. An absent row means the budget has never been spent and opens the gate, matching the INSERT ... ON CONFLICT DO NOTHING the commit path already relies on. * test(relay): assign the closed budget field once so the case runs on Postgres The two gate cases zeroed both `next_dispatch_at` and `paused_until` and then set the one under test, which names that column twice in a single `SET`. SQLite accepts it; Postgres raises "multiple assignments to same column", so both cases failed whenever `ORCA_IDLE_REHOME_POSTGRES_URL` pointed the suite at a real server -- exactly the backend the gate has to hold on. Setup already leaves both fields at 0, so naming the other one bought nothing. |
||
|
|
ce5d8c02d4 |
fix(relay): wait out a cold proxy at boot instead of exiting the cell (#21516)
* fix(relay): wait out a cold proxy at boot instead of exiting the cell A cell container starts its relay process beside a cloud-sql-proxy that is itself still dialling. The first pool acquire therefore competes with a proxy cold start, and the 2s connect timeout that protects the request path fires before the proxy is listening. `openRelayDatabase` rejects out of the region backfill, the top-level await rejects, and the process exits; COS restarts the container and the next boot succeeds 1-3s later. The 2026-09-18 fleet roll saw 0-7 of these per cell, including on cells with zero hosts, so it is a property of the boot sequence rather than of database load. The boot open now retries on transient errors only, inside a 45s wall-clock window with exponential backoff from 250ms to 4s. The classifier is the one the request path already uses, so a rejected credential or a bad URL still exits on the first attempt. Each wait logs `orca_relay_boot_database_retry` and a give-up logs `orca_relay_boot_database_failed`, both with the bounded error category, so a rollout can tell a slow boot from a stuck one without reading container exit codes. The bounded startup retry is lifted out of `reconcileCellAdmissionAtStartup`, which had the same loop; its attempt budget, flat delay, and both log events are unchanged (a flat delay is a cap equal to the base). * fix(relay): retry the boot open only when Postgres is unreachable The boot open re-runs the schema apply, and applyPostgresSchema refuses to repeat a DDL lock timeout on purpose: relation locks are granted in queue order, so a repeat parks every writer behind the same statement again. Gating the boot retry on the full request-path classifier would have re-queued it up to 16 times in 45s on sustained 55P03 - the mechanism behind the 2026-09-16 outage. The boot call site now has its own predicate: pool connect failures (both connect-timeout messages and an acquire-marked early-ended socket) plus 08001 and 08006. Lock and overload SQLSTATEs - 55P03, 57014, 53300 - exit on the first attempt. The retry predicate moves onto the policy because what a step re-runs, not the request path, decides what it may repeat; the startup reconcile keeps the full classifier, which is what lets it wait out 55P03. |
||
|
|
3336933cc8 |
fix(orchestration): list worker Dispatches newest first and warn when the page truncates (#21523)
* fix(orchestration): list worker Dispatches newest first and warn when the page truncates `worker-list` paged `ORDER BY d.rowid ASC` with a 100-row cap, so a Run with more than 100 Dispatches answered with its OLDEST 100. The workers a coordinator had just started, and the rows carrying `projection.attention.requiresAction`, were on a page nobody fetched, while `counts` and `page.total` covered the whole Run so the receipt read as complete. One ordering, flipped: the detail query and the terminal-state scan it pages by both order `d.rowid DESC`, and the cursor fence walks down (`d.rowid < anchor`). The snapshot fence is unchanged — `d.rowid <= snapshot` still means "nothing created after the first call". When the page truncates the receipt now carries a `warnings` string, the same shape `worker-output` already uses, alongside `page.hasMore`. Text output keeps its `More: --cursor` line and prints the warning through the block it already had for partial-host errors. Refs STA-7861 * fix(orchestration): make the worker-list truncation warning true on every page The warning said "Showing the N newest of T Dispatches" unconditionally, but `hasMore` is true on every page except the last, so page 2 of a 300-Dispatch Run claimed to be the newest 100 while showing rows 200..101. This PR exists because a receipt read as complete when it was not; that warning shipped a receipt that read as the newest page when it was not. The page count and the ordering are separate facts, so state them separately: "Showing N of T Dispatches, newest first; more are on later pages." True on page one and page N alike, no extra state. The 105-row case only ever reached the last page, where `hasMore` is false, which is why it missed this; a new case walks 6 Dispatches at `--limit 2` so a page that is truncated AND not page one is covered. Also: the `worker-list` --help note and the recovery-and-cleanup reference still described the oldest-first contract; both now say newest first. The snapshot test is renamed to the property it actually proves — under DESC a later insert is unreachable by arithmetic, so what the `d.rowid <= snapshot` fence still earns is pinned `page.total` and `counts`, not row exclusion. The continuation comment says "below the anchor" next to `d.rowid < ?`, and the two SAFETY rationales now say what they are: an unchanged cast the gate flagged because the diff moved inside its span. Refs STA-7861 |
||
|
|
b5d22c8ba3 | refactor(editor): remove unused markdown serializer shim | ||
|
|
c5733e812a | fix(opencode-usage): count cache-read tokens (#21522) | ||
|
|
2bcfc3000d | fix(editor): honor edited markdown destinations | ||
|
|
d253dd0e1d |
fix(mobile): the page bridge accepts every reply native accepts and settles what it refuses (OTA phase C, C0.8) (#21511)
* chore(mobile): repin the recording corpus to main's tip (OTA phase C, C0.8) C0.5 pinned `baseline` to its own branch commit, which the squash-merge made unreachable, so `rpc-recording-pin-guard.mts ancestry` fails on main and `--record` refuses to run at all. Repin to main's tip and refresh every header from it. Header-only, and the corpus proves it: across all 787 goldens exactly two distinct lines changed, the old `baseline` and the new one. `recorderSha256` did not move, because nothing under the recorder's own directory did. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): accept at the page bridge every reply the native client accepts `BridgeReplyPayloadSchema` required `_meta` on both arms. The predicate the native client applies to a reply off the wire, `transport/rpc-response-shape.ts`, requires none, and `src/shared/runtime-rpc-envelope.ts` — the envelope clients and runtimes share — makes `_meta` optional on a failure with a nullable `runtimeId`. The page's reader was strictly narrower than the transport it stands in for, so replies the phone accepts today were refused, dropped with a diagnostic, and settled nothing. The reader is now that predicate itself, imported rather than restated: one module owns the shape, and a widened reader is safe in both directions. `{ ok: true }` with no `result` key stays refused, because `isRpcResponse` drops it too. Killer test: `the reply reader is the native acceptance predicate` compares the page's verdict against `isRpcResponse` over eleven payloads; six of them were red before this change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): settle the exchange a refused bridge frame was answering A host frame the page's reader refuses was reported and dropped, and the request it answered stayed pending for the life of the document. A screen has no recovery from that: `sendRequest` never settles, so nothing rerenders and nothing retries. The page now salvages the id out of the refused frame, through the same caps the reader applies, and settles it — but only when it already holds that exchange, so a salvaged id reaches nothing the page did not open itself. A request rejects with `BridgeReplyRefusedError`, which carries the refusal and is now marked delivery-unknown at construction: the shell answered, so the desktop has already run the request and a caller told this was a definite failure would offer to retry what already happened. A subscription ends the way a refused `subscribe` ends today. `bridge-rpc-client.ts` was at the 300-line ceiling, so the inbound routing moved to `bridge-client-inbound-frames.ts` and the diagnostic vocabulary, which both sides raise, to `bridge-client-diagnostics.ts`. No `max-lines` disable. Killer tests: `settles the request a reply it could not read was answering, on the same turn` (hung to a 5s timeout before) and `ends the stream an event it could not read belonged to`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name what the bridged replay still excludes, and ratchet the pin The class this harness was landed to name is closed, so the pin says so: `reply-meta-required` is 0 and has no exclusion reason, which is what `divergence-classes.test.ts` now requires of any class the run is allowed to count. `BRIDGED_PARITY_EXCLUSIONS` gives each remaining class the reason it is a bound on the claim rather than a defect, the run prints the excluded total beside those reasons, and one assertion states the whole claim: nothing diverges that no predicate has named. Measured, not argued, for `params-undefined`: all ten scenarios that send an own property valued `undefined` already record the key as absent from the bytes their request put on the wire — `tw-smart-search-all-providers` records `{"filter":"assigned","limit":50}` with `workspaceId` already gone — so the bridged run sends the identical frame. What differs is the object `ScriptedRpcTransport.complete` matches a scenario step against, one level above any serialization. The projection named as the fix is not where it lives: `projectMobileRpcRequestParams` rewrites `worktree.ps` alone, none of the ten calls it, and the bridge host forwards into the same `StableLogicalRpcClient` the native screens hold, so there is no shell-side copy to move. `divergence-evidence.test.ts` asserted the narrow reader that the `_meta` widening removed; it now pins that the counterfactual changes no verdict, which is what makes it a detector for that reader coming back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say the remaining classes total 391 in one line Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read the throw a bridged golden died on, not just the scenario The `params-undefined` arm named a class from two facts that never met: the run threw, and the scenario somewhere scripts a param key valued `undefined`. Any failure inside those ten scenarios was that class, reported by nothing. A seeded wire bug — one extra own key on every request's params — put 627 goldens in `unclassified` and still left `params-undefined` at exactly 33, all corrupted; scoped to `linear.listIssues` alone it stayed green. The evidence now carries the step the scripted transport refused, the paths the scenario values `undefined` on it, and every path where the params that arrived differ from the ones it scripts — read off the frames the page posted, not off the message. The class needs every path that moved to be one of the scripted `undefined` ones, and at least one to have moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): cancel the stream a refused frame belonged to, not just end it Ending a stream deletes the page's record and posts nothing back. That is right for `end` and `error`, where the shell has already retired it, and wrong for a frame this reader refused: the shell is still serving that stream, so later valid frames on the id were dropped with no diagnostic, no ack went back, and the only release left was the host's overflow backstop, which counts unacked frames and so never fires on a stream that has gone quiet. A newer shell adding a member to a closed list — a new `binary.format`, say — lands exactly there. `abandon` posts the cancel and tells the listener; `failExchange` picks it over `end` for the one path where the shell has not let go. Six goldens move to a new named class for what the cancel changes in the replay. The unsubscribe it publishes is a physical payload the native run has no counterpart for, and it takes the recorder's next occurrence name for that method, so the scenario stops matching before there is a recording. Four came from `result-absent-observation` and two from `write-ordinal`; the sum over the corpus is unchanged and nothing stopped replaying byte-identically. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold the bridged pin to the size of the corpus Each class in the pin is an upper bound and `identical` a lower one, so nothing stopped a single class being loosened on its own: raising `params-undefined` by one passed every assertion the flagged run makes. The comment already claimed the total was fixed at the corpus; this counts the goldens on disk and checks it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name the two refusals that can settle nothing at all `oversized` is decided on the raw string and `malformed-json` on a parse that did not finish, so neither frame ever yields an id and neither can settle the exchange it was answering. Nothing on the page settles those: `close` or a shell replacement releases the slot, and otherwise it is held for the life of the document. Neither arises from a host that is behaving — it chunks at the frame cap and answers a body over `BRIDGE_MAX_REPLY_BYTES` with an `error` frame — but the boundary was unstated and untested, which is how it reads as covered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say which way each number in the bridged pin may move The comment said the pin moves down and never up, two lines above a class that went from 338 to 341 when a fix changed which difference a run meets first. Both are true of different halves of it, so say which: `identical` only moves up, a class only moves down, and two excluded classes may trade members in one edit that leaves the sum alone. The sum is now checked, so that trade cannot hide a golden that stopped replaying byte-identically. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin which goldens are in a class, not only how many A count is blind to a trade. Every predicate reads the scenario rather than the frame the page refused, so a golden that started refusing for real can walk into an excluded class while another walks out, and the counts, the sum and the `identical` floor all still hold. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
73a58bd21a |
feat(session-search): resolve Workspace and Project scope on the host (#21509)
* refactor(session-search): move the AI Vault project key to shared The host must spell a project key exactly as the client does, so the two sides share one function instead of two copies that can drift. * feat(session-search): add a scope identity to the search request The panel cannot keep translating a project into one path per worktree: a repo with 580 of them exceeds the 64-path cap and the search fails outright. The request now carries the scope's identity instead, and a host acknowledges the scope it resolved so a client can tell a scoped answer from an old host's unscoped one. * feat(session-search): resolve a scope identity on the host that answers Every entry point already funnels into searchSessionService, so the identity becomes paths there once: native, WSL, SSH and relay hosts cannot disagree. A host that does not know the workspace or project answers scope-unknown rather than widening the search to everything it has. * test(session-search): pin how a host resolves a scope identity Covers prior paths, a workspace another now claims, folder workspaces, a custom worktree base path, flat placement where the global root belongs to every project, and the 580-worktree fold the panel's path list could not do. * fix(session-search): type the scope store by what the catalog reads A full Repo/Project/ProjectHostSetup requirement forced test stores to stand up rows the catalog never looks at. * feat(session-search): send the scope identity from the panel Workspace and Project name what to narrow to; All sends nothing. A host that answers a scoped search without acknowledging it is reported as needing an update, and none of its hits are shown, because they are not this scope's. * test(session-search): pin the new-client-against-old-host skew An old host strips the identity and answers with every session it has, and the answer is well-formed. The missing acknowledgement is the only evidence, so the merge drops those hits and names the host instead. * test(session-search): pin the identity and acknowledgement across every entry point IPC, the runtime RPC method, the relay handler and the shared remote client each carry the identity out and the acknowledgement back, and the relay -- which has no repo catalog -- reports the scope rather than widening the search. * fix(session-search): acknowledge the scope on an all-computers merge The merge built its results without the acknowledgement, so the renderer read it as an old host, dropped every hit and asked for an update. That is the default path: the panel defaults to Workspace and the host scope falls back to All. Per-host skew is still reported through `hosts`. Host-resolved paths no longer travel in `filters.scopePaths`. That field is capped at 64 for the clients that write it by hand, and the scanner child re-parses the request with the same schema -- so a project whose worktrees do not share one managed directory failed at 65 paths with "not ready". They ride beside the request now, where no wire cap applies. Managed directories come from buildKnownOrcaWorkspaceLayouts, so a workspace root the user has since moved away from is covered too. A workspace identity is resolved through this host's own worktree registry rather than the directory embedded in the client-supplied id. * test(session-search): follow the service search signature Host-resolved paths are a second argument now, so the call-shape assertions that pinned a one-argument call name it. * fix(session-search): answer consent and readiness before an unknown scope The registry short-circuited an unresolvable scope before current.search ran, and current.search is where disabled and not-ready are decided. A host with indexing off that lacks the project told the user it did not have the workspace, which they cannot act on. The verdict now travels to the service beside the request, and the service answers it after its own checks. * fix(session-search): acknowledge only a scope that resolved An unknown verdict is still a verdict, and it was being acknowledged as if the host had narrowed. The skipped banner also counted only 'searched' as having resolved the scope, so a host that resolved it and came back stale or timed out let the scope lines reappear where they explain nothing. * refactor(session-search): drop the version-mismatch receipt No stable release ships search, so the only hosts that have it and predate `within` are dev and ad hoc builds. The acknowledgement, the needs-update outcome and the copy behind it would be permanent dead weight from the first stable release on. The scope-unknown outcome and the off / not-ready / unknown ordering stay. Also trims this PR's new docblocks to the repo's one-line why rule. |
||
|
|
fc3a5d7326 |
chore(mobile): repin the recording corpus to main's tip (OTA phase C, C0.8) (#21510)
C0.5 pinned `baseline` to its own branch commit, which the squash-merge made unreachable, so `rpc-recording-pin-guard.mts ancestry` fails on main and `--record` refuses to run at all. Repin to main's tip and refresh every header from it. Header-only, and the corpus proves it: across all 787 goldens exactly two distinct lines changed, the old `baseline` and the new one. `recorderSha256` did not move, because nothing under the recorder's own directory did. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
f2f2c37fc6 | Update README downloads badge | ||
|
|
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 |
||
|
|
0b57ce0295 |
fix(attention): count both terminal and chat siblings when clearing workspace unread (#21274)
* fix(attention): count both terminal and chat siblings when clearing workspace unread A workspace holding a terminal pane and a structured chat tab built its "anything still unread here?" inventory from the terminal tab list alone, so acknowledging the visible terminal cleared the workspace's unread flag while the chat's completion marker was still outstanding. The chat's unread was lost with nothing left to relight it. Structured chats now have their own attention-surface adapter, addressed by the pane key the status producer already publishes — `<unifiedTabId>:<sessionLeaf>` — with the unified tab id as its container id. Acknowledgement unions both surface kinds' remainders, so either kind's hidden sibling holds the workspace lit. * fix(attention): rescan when focusing a split group |
||
|
|
209d2d8df6 |
build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) (#21475)
* build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) The page bundled as one 8.16 MB script because every route was a static import. The route manifest now defers each screen behind `import()`, the build is esm with splitting on, and the document loads the entry as a module. What the browser parses before the first route can paint drops from 8.16 MB to 908 KiB; the whole page still weighs the same. Two budgets hold it: the chunk count, which catches a split running away, and the bytes the entry reaches by static import, which catches it collapsing back. The second is the one that matters, and it is measured from esbuild's metafile because only that says which import is static. The RequireContext stays synchronous, since expo-router reads keys() to build the route tree before anything renders. A lazy module cannot answer `unstable_settings` or `ErrorBoundary`, which expo-router reads off the namespace, so a test holds that no route in the subtree exports either. The render check now waits for the route's own text: the entry's mount signal lands while the route chunk is still being fetched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a route's synchronous exports from esbuild, not a regex `export { x as ErrorBoundary }`, `export class ErrorBoundary` and a re-export all reach the namespace without matching the declaration pattern the guard was matching, so the lazy manifest dropped the boundary and the page painted blank. A star re-export is now reported rather than read as clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say that the entry budget is not a per-route opt-out Measured: statically importing one route already breaks the 3 MiB bound for 5 of the 14. The hatch only works for a layout node, which is the only place expo-router reads a synchronous export from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): derive the chunk ceiling from the route count 64 was three routes of headroom over the 53 chunks 14 routes measure, so C2's routes would have failed on a number measured before they existed. Four per route plus 16 tracks the measured slope; the entry-bytes bound stays the real budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the web entry's dead suspense boundary expo-router wraps every screen in its own, so this one never fires; all nine render checks stay green without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that a client-side navigation fetches the next route's chunk Goes red with splitting off: the tasks screen paints out of the entry and no new script is fetched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name every bundle output by its bytes, not by esbuild's path hash esbuild's [hash] is over the metafile's input keys, which are paths relative to absWorkingDir, so a checkout at another depth or with node_modules as a symlink named a byte-identical chunk differently and shipped a different buildId for one commit. Outputs are now renamed leaves-first to the sha256 of their final bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fail the build on a route the lazy manifest would strip The guard ran only in a test while the docstring said it failed the build. It now runs in bundleMobileWebApp and names the route and the export. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): derive the asset ceiling from the chunk ceiling and the images A flat 128 stopped agreeing with the chunk ceiling at 18 routes, where the asset count would have failed first and named the count instead of the split. Chunks plus images plus the document keeps the chunk ceiling the one that trips. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the route-manifest tests out of the bundle builder's The builder's test file passed 600 lines. The route manifest, the synthesized RequireContext and the web entry are their own subject and move together. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the export guard the builder's route-source loaders Without .js as jsx the guard reported a React Native .js route carrying JSX as "JSX syntax extension is not enabled" instead of reading its exports. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the navigation fetches the tasks route's own chunk "some new script arrived" passed on any fetch. The builder now names the chunk each route lands in, read off the metafile, and the check asserts that exact path arrived and was not already loaded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): resolve a route's realpath before matching it to its chunk esbuild writes metafile input keys after resolving symlinks, so every scratch route tree under /var on macOS reached no output and failed the build. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fail the build when the asset ceiling outgrows the shell's map The derived ceiling had no upper bound, and the native shells return null for a manifest over their own 256 rather than truncating it. At 42 images the formula crosses that at 50 routes, inside what Phase C adds, so the build would stay green while the phone got nothing. The number is read from the contract through esbuild, not restated here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover the two hard stops in the content-addressed naming Both throws only ran through a whole bundle before, where neither can be provoked. A cycle and a route no output claims are now asserted directly; each test goes red when its throw is removed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): exit the app-bundle build on one line, not a stack The route-export guard fails this script by design, and a raw stack put the route and the export name under twelve frames of node internals. Mirrors the verifier's exit; the message is printed as thrown because every throw on this path already names its source. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a85e580e51 |
fix(orchestration): stop the sender-terminal refusal recommending another pane's handle (#21097)
* fix(orchestration): stop the sender-terminal refusal recommending another pane's handle The structured-session guard told callers to pass `--from <terminal-handle>`, but the explicit-flag branch returns before that guard runs — so following the advice succeeds, against a handle that necessarily belongs to a different pane, and the next `check` consumes that pane's unread mail. Both refusals now say what is actually true: no handle names a structured chat session, and a caller that does have one should pass its own. Also pins ORCA_STRUCTURED_SESSION in the gate CLI test, which until now decided which refusal it exercised from ambient environment. * fix(orchestration): route the lifecycle-send refusal to the structured message `orchestration send --type worker_done|heartbeat` refuses in the send handler before `resolveOrchestrationTerminalHandle` runs, so the structured guard never saw the case a structured session hits most: the canonical worker lifecycle report. That caller was still told to pass `--from` with "your own terminal's handle" — which it does not have, so any handle it picked would belong to another pane. `throwNoActiveSenderTerminal` now derives which refusal fits instead of each call site deciding: marker set AND no handle means no identity exists, so the structured refusal applies. A stale `ORCA_TERMINAL_HANDLE` is deliberately excluded — that caller does have an identity, it just went stale, and keeps the advice to re-run under a live one. Also corrects the guidance itself (`--agent` is a `worktree create` flag; `terminal create` has no such flag), aligns the SSH fallback wording with its local twin, and pins ORCA_STRUCTURED_SESSION in the send tests, which until now decided which refusal they exercised from ambient environment. |
||
|
|
66e0847398 |
fix(agent-status): stop an auto-reviewed Codex approval reading as "Needs You" (#21389)
* fix(agent-status): stop an auto-reviewed Codex approval reading as "Needs You" Codex runs its PermissionRequest hook as decider #1, ahead of both its own review agent and the user, so the event means "a decision is being made", not "a human is blocked". Under the "Approve for me" posture the review agent resolves it seconds later, so every gated tool call drove the pane from Working to Needs You and back, plus a desktop notification each time. The execution host now reads the turn's approvals_reviewer off the rollout it already tails for subagent reconciliation, and keeps a reviewer-owned approval as working. Positive evidence only: an absent field, an older rollout, or an unreadable file all still raise the wait, so this can never hide a real prompt. Splits the incremental rollout JSONL cursor out of the subagent transcript module, which the new reader pushed over the file-length cap. * fix(agent-status): avoid stale Codex approval ownership * fix(agent-status): reconcile Codex child approval ownership * perf(agent-status): avoid reads for Codex child activity * fix(agent-status): scope Codex reviewer ownership by transcript |
||
|
|
ddbb194585 |
feat(mobile): page-side RpcClient over the web shell bridge (OTA phase C, C0.4) (#21467)
* feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) One page document's end of the bridge: page frames in through the C0.1 reader, one RpcClient behind it, host frames out. Requests forward with the arity the page used and answer with the verbatim RpcResponse, chunked when it is over the frame cap; a rejection crosses as the five-field capture instead. Subscriptions carry a seq and an unacked window, and end with `overflow` rather than dropping frames a reader cannot see are missing. The fence is structural: the protocol names no host, so the client is whichever this host was built with, and the in-flight caps the page is told about in `init` are enforced here rather than trusted from there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3) The channel opens on the session B4 put on screen and closes with it. The session id is B4's: nothing new is minted, and a remount is a new one, which is what makes a dead page's frames fail the native origin check. Both halves are stamped with the session they belong to, because React swaps refs during the commit and runs the retiring effect's cleanup after it — a host disposing on a remount would otherwise post its teardown into the page that replaced it. `bridgeEnabled` is derived from the session step alone, since the native side treats a prop change as a reload. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the bridge fence holds for traffic, not just for answers A mutation that dropped the post-teardown guard in `receive` survived: the teardown case only fed a frame whose answer the outbound guard already swallowed, so nothing observed that a dead page could still reach a live client. Both teardown paths now feed a request, a subscribe and a notify, and assert the client saw none of them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the hook's frames through the page's own reader `JSON.parse` returns `any`, and taming it with an assertion is a cast the gate refuses and a check nobody gets. Reading each posted frame through `readBridgeHostMessage` types it and proves the same thing the host's own suite does: a frame the page would refuse is a frame that never arrives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): list the bridge host as a raw request port owner The boundary ratchet reads a `.sendRequest` access as a call site, and the host has three: one per arity the page can use. It is not a call site. It picks no method, reads no reply and decides no acceptance — the page names the method and runs the typed operation over the client this carries, which is what the C0 design put page-side so `runRpcOperation` stays unchanged there. That makes it an owner, beside the socket and relay senders, not a migration backlog entry. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): page-side RpcClient over the web shell bridge (OTA phase C, C0.4) Every member of the native contract, carried over the C0.1 envelope so the screens above it cannot tell a bridge from a socket: requests keep the arity the caller used, a host RpcFailure resolves as data while a rejection is rebuilt with its class and its delivery-unknown mark, subscriptions stream with periodic acks, and the synchronous getters read a cache primed by init rather than answering before they know. A state whose generation went backwards is refused and re-asked for, because a shell rebuilt under the page makes what the page holds the newer of the two. close settles what the page owns and never touches the shell's client, which the native screens and the host catalog still share. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): run the page client against the shell host over an in-memory port pair One FIFO per direction and delivery on a microtask, which is what C0.5's golden replay needs: a subscribe that overtook a sendRequest would move the recorder's shared ordinal, and anything stronger than a microtask moves a virtual millisecond. Every member round-trips through the real host over a fake client; the frame-level suite covers what no pair can reach, including the handshake backoff, refusals and the binary lane C6 will fill. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): list the page bridge client as a raw request port owner Both ends of the bridge hold the port as a transport: one forwards raw requests and the other offers them, and neither picks a method or reads a reply. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the assembler discard no abandoned request can reach A request is only abandoned when its frame never left the page, so the shell was never told the id and no part can have arrived under it. Says what actually keeps an omitted param omitted while it is here: JSON drops an undefined value, so the spread states the intent rather than producing the result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close the three gaps a mutation sweep found in the page client A settled id has to give its assembler slot back, or 64 replies that were cut short before an error leave the page unable to read the next chunked one. Close says goodbye once rather than cancelling each stream first. And the read guard is only observable through a port that ignores its own unsubscribe, which is what the harness can now be. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove a stream that overflows inside subscribe is unsubscribed A client that emits synchronously from `subscribe` can retire a stream before its unsubscribe exists to be stored. The identity check that calls it instead had no test; deleting it left the suite green while the client's stream leaked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the bridge host over in the commit, not after it A client swap that keeps the session id leaves the handler's own fence inert: until the passive effect ran, a native frame reached the retiring host and the client it closed over. A layout effect swaps both inside the commit. Teardown on unmount now runs while the view is still attached, so a pending request is answered delivery-unknown instead of being dropped on the floor. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold a refused page frame to one warning per page A page that sends one bad frame usually sends many, and a line each buries the first — the one that says why. Same bound the host already keeps on a failing post, applied per kind and reset when a new page gets a new host. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound the page's terminal viewport at the bridge contract A viewport crossing the bridge is written into the cached subscribe params of every stream naming that terminal, including the native terminal screen's, and the desktop refuses cols over 1000 or rows over 500 when those streams resubscribe. Unbounded, one page could kill streams it never opened; the frame is refused instead, and the bound is pinned to the desktop's own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop the page's close from latching the bridge host shut One view carries every document the shell loads, so the page that says `close` is not the last one. A latched host dropped the next document's `ready` in silence, and a page that re-sends `ready` on a backoff would retry forever with nothing posted and nothing logged. Close now cancels what the page owned and leaves the host live; only dispose shuts it, and a frame arriving after that is diagnosed rather than dropped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a throwing client or post inside the bridge host The `state` frame is sent from inside the client's own state-change fan-out and a notify runs on the native event handler that delivered the page's frame, so a synchronous throw from either escapes into a loop the bridge does not own and takes unrelated listeners with it. Both are fenced and reported once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove an ack releases the stream's unacked bytes The frame window reopens on ack through the splice, so deleting the byte release left every existing test green while a long-lived stream of large frames would end with overflow on its first frame after an ack. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pass the commit-window harness its children as a prop `createElement`'s variadic children do not satisfy a props type that declares `children`, so the file dropped out of the tests typecheck ratchet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): render the harness from the commit-window wrapper, not as children A props type that declares `children` is what `createElement`'s variadic form does not satisfy, and passing it as a prop instead trips the react rule. The wrapper renders the harness itself, which is the parent position the layout effect ordering needs anyway. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin the desktop viewport bound by reading it, not importing it Mobile may not pull an rpc-contract *value* into its bundle, and the boundary test that enforces that scans this test file too. The pin reads the schema's own source instead, so drift in either bound still fails loudly. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): settle a refused subscribe as the stream it was The shell answers a refused `subscribe` with `error` on the stream's id. Routing that to the pending requests dropped it, because no request is open under that id: the page heard nothing and kept the slot forever. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report a reply or an error the page has no id for Silently dropped before. Nothing recovers it in place, but a frame the page cannot place means the two ledgers disagree, which is worth a line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say disconnected on close instead of going silent Every native client publishes the transition and keeps answering its last snapshot; the screens read both. The page's client cleared the cache instead, so a closing page left its listeners on a dot that never moved and every getter throwing underneath it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a closed page client go inert, not throw An unmounting screen still calls, and nothing on a teardown path catches. Subscribe hands back a no-op dispose and the notifies do nothing, as the native client's do, and a request rejects rather than throwing past the caller's catch. A call before init still throws: that one is a bug. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): lift the init handshake out of the page client The backoff that asks the shell for a session is its own concern, and the client had grown past the file's line budget holding it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the cancel a page owes for a stream already ended A screen unmounts on its own schedule, routinely after the shell gave up on the stream. Only the double-dispose order was covered. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state what the page client does after close The doc gave the pre-init rule and stopped; the after-close rule is the opposite one, and subscription failures have no channel but a diagnostic. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): read the shell's page channel as a client transport The document-start installer leaves `postMessage` and one `onmessage` slot, the intersection of what the two platforms inject. A page opened outside the shell has no global at all, so reading it answers null rather than throwing: the bundle still has to open in a browser. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the page its bridge client instead of a placeholder The web provider now builds BridgeRpcClient over the shell channel and mounts nothing until `init` lands: every member throws before a session, and a screen that rendered first would record its first frame against a client that has none. Outside the shell there is no session coming, so the placeholder stays and the route tree mounts at once, which is what the Route A render check exercises. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): declare the page provider test's probe instead of casting it Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve the bridge to one document at a time A page's `close` now ends that document's turn: until the next `ready` claims the view, every other frame is dropped and diagnosed instead of reaching the client, and nothing is posted. Without the fence a straggler from the closed document was still forwarded, and a `state` frame from the still-running client landed in the replacement document before its `init`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the request cap against the calls, not the page's ledger `sendRequest` has no cancel, so a request the page cancelled or closed out keeps running on the desktop until it answers. The cap now counts those calls until each settles; counting the pending map let a page interleaving `close` with batches hold many more than the cap `init` advertises. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ack ratio to the shell's window, not a copy of it The ack interval test held 256 and 4 MiB as literals, so narrowing the shell's window would have left the page acking too late with the test still green. The comment naming the test that pins the ratio pointed at the wrong file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give back the slot of a subscribe that never left the page A post that threw left the stream in the page's ledger with nothing open on the shell's side, so 32 of them exhausted the subscription budget for the life of the document. The slot goes back and the listener hears a terminal error result, which is what the native client does with the same failure. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): end a page stream through its listener, not only the log A stream the shell ends or fails now reaches its listener as a terminal error result, the way the native client's emitError does. A consumer reads that result: host-worktree-refresh clears the flag that says the event stream is live, and without it the worktree list stops updating for the life of the document. A dispose the page asked for stays silent. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): settle the old shell's work before adopting a new session A second `init` naming a different sessionId is a rebuilt host with empty tables: every pending request and every open stream the page still held belonged to the shell that is gone. They now settle delivery-unknown and end through their listeners before the new session is adopted. A second `init` for the same session is what a re-asked `ready` earns, and keeps everything. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): take the page's streams out of the ledger before failing them A listener that resubscribes while the old shell's streams are being ended is opening one against the shell that is arriving; draining the map first is what keeps this loop from tearing that one down too. Fixes the lint the previous commit left behind. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say how long a reply assembler's refusal actually lives The tombstone is not kept forever: the request ledger discards the id as it settles the caller, so it normally outlives only the rest of the reply that raised it. The bounded map is there for the ids nothing settles. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ceiling the ready backoff stops widening at An unclamped backoff reads the same for the first minute and then leaves a page asking once an hour into a shell that is still booting behind it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why the document fence carries no epoch Page frames reach the shell through one native listener per platform, so a straggler from the closed document lands before the next document's `ready` and the flag alone catches it. An echoed epoch would be a wire change for nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
f2be6299c8 |
feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) (#21459)
* feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) One page document's end of the bridge: page frames in through the C0.1 reader, one RpcClient behind it, host frames out. Requests forward with the arity the page used and answer with the verbatim RpcResponse, chunked when it is over the frame cap; a rejection crosses as the five-field capture instead. Subscriptions carry a seq and an unacked window, and end with `overflow` rather than dropping frames a reader cannot see are missing. The fence is structural: the protocol names no host, so the client is whichever this host was built with, and the in-flight caps the page is told about in `init` are enforced here rather than trusted from there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3) The channel opens on the session B4 put on screen and closes with it. The session id is B4's: nothing new is minted, and a remount is a new one, which is what makes a dead page's frames fail the native origin check. Both halves are stamped with the session they belong to, because React swaps refs during the commit and runs the retiring effect's cleanup after it — a host disposing on a remount would otherwise post its teardown into the page that replaced it. `bridgeEnabled` is derived from the session step alone, since the native side treats a prop change as a reload. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the bridge fence holds for traffic, not just for answers A mutation that dropped the post-teardown guard in `receive` survived: the teardown case only fed a frame whose answer the outbound guard already swallowed, so nothing observed that a dead page could still reach a live client. Both teardown paths now feed a request, a subscribe and a notify, and assert the client saw none of them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the hook's frames through the page's own reader `JSON.parse` returns `any`, and taming it with an assertion is a cast the gate refuses and a check nobody gets. Reading each posted frame through `readBridgeHostMessage` types it and proves the same thing the host's own suite does: a frame the page would refuse is a frame that never arrives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): list the bridge host as a raw request port owner The boundary ratchet reads a `.sendRequest` access as a call site, and the host has three: one per arity the page can use. It is not a call site. It picks no method, reads no reply and decides no acceptance — the page names the method and runs the typed operation over the client this carries, which is what the C0 design put page-side so `runRpcOperation` stays unchanged there. That makes it an owner, beside the socket and relay senders, not a migration backlog entry. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove a stream that overflows inside subscribe is unsubscribed A client that emits synchronously from `subscribe` can retire a stream before its unsubscribe exists to be stored. The identity check that calls it instead had no test; deleting it left the suite green while the client's stream leaked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the bridge host over in the commit, not after it A client swap that keeps the session id leaves the handler's own fence inert: until the passive effect ran, a native frame reached the retiring host and the client it closed over. A layout effect swaps both inside the commit. Teardown on unmount now runs while the view is still attached, so a pending request is answered delivery-unknown instead of being dropped on the floor. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold a refused page frame to one warning per page A page that sends one bad frame usually sends many, and a line each buries the first — the one that says why. Same bound the host already keeps on a failing post, applied per kind and reset when a new page gets a new host. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound the page's terminal viewport at the bridge contract A viewport crossing the bridge is written into the cached subscribe params of every stream naming that terminal, including the native terminal screen's, and the desktop refuses cols over 1000 or rows over 500 when those streams resubscribe. Unbounded, one page could kill streams it never opened; the frame is refused instead, and the bound is pinned to the desktop's own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop the page's close from latching the bridge host shut One view carries every document the shell loads, so the page that says `close` is not the last one. A latched host dropped the next document's `ready` in silence, and a page that re-sends `ready` on a backoff would retry forever with nothing posted and nothing logged. Close now cancels what the page owned and leaves the host live; only dispose shuts it, and a frame arriving after that is diagnosed rather than dropped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a throwing client or post inside the bridge host The `state` frame is sent from inside the client's own state-change fan-out and a notify runs on the native event handler that delivered the page's frame, so a synchronous throw from either escapes into a loop the bridge does not own and takes unrelated listeners with it. Both are fenced and reported once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove an ack releases the stream's unacked bytes The frame window reopens on ack through the splice, so deleting the byte release left every existing test green while a long-lived stream of large frames would end with overflow on its first frame after an ack. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pass the commit-window harness its children as a prop `createElement`'s variadic children do not satisfy a props type that declares `children`, so the file dropped out of the tests typecheck ratchet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): render the harness from the commit-window wrapper, not as children A props type that declares `children` is what `createElement`'s variadic form does not satisfy, and passing it as a prop instead trips the react rule. The wrapper renders the harness itself, which is the parent position the layout effect ordering needs anyway. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin the desktop viewport bound by reading it, not importing it Mobile may not pull an rpc-contract *value* into its bundle, and the boundary test that enforces that scans this test file too. The pin reads the schema's own source instead, so drift in either bound still fails loudly. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve the bridge to one document at a time A page's `close` now ends that document's turn: until the next `ready` claims the view, every other frame is dropped and diagnosed instead of reaching the client, and nothing is posted. Without the fence a straggler from the closed document was still forwarded, and a `state` frame from the still-running client landed in the replacement document before its `init`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the request cap against the calls, not the page's ledger `sendRequest` has no cancel, so a request the page cancelled or closed out keeps running on the desktop until it answers. The cap now counts those calls until each settles; counting the pending map let a page interleaving `close` with batches hold many more than the cap `init` advertises. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
381a3da46f |
feat(build): Route A, the phone's host routes bundled for the web, dark (OTA phase C, C0.7) (#21449)
* refactor(mobile-web): share the bundle manifest assembly with a second builder Manifest assembly and the on-disk write move to writeMobileWebBundleTree, and the helpers the Phase C app builder needs become exports. No behaviour change to the shipped bootstrap bundle. The CRLF guard grows two exemptions it needs once it is pointed at mobile/src: the image and font extensions .gitattributes already pins -text, and the gitignored webview engine modules the postinstall writes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): web entry for the host route tree, and its two transport siblings The entry mounts app/h on react-native-web through expo-router's own ExpoRoot. It lives inside mobile/ so one React resolves, and supplies RpcClientProvider itself: the route tree starts below the native root layout that owns it. route-manifest.ts is a real typed module whose body the builder replaces -- esbuild has no require.context. A virtual specifier would need an ambient declaration and would leave the entry unchecked. Two .web.* siblings, both listed with a reason in web-overrides.json: the transport substitution point (a placeholder client until C0.4 lands BridgeRpcClient) and the device token store, whose native path imports expo-secure-store, which is {} on web. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(build): build:mobile-web:app, the phone's host routes bundled for the web Same builder shape as the Phase A bootstrap into a separate out/mobile-web-app, with the same manifest and the same two-scratch-build determinism check. Dark: build:mobile-web, packaging and the A2 census are untouched, and C1 is what flips build:release. Six shims, each a named Metro or RN Web gap. Images are emitted as same-origin hashed assets rather than data: URLs, because the shell's CSP sets img-src 'self'; the render check under that exact header is what found it. The script is referenced root-absolute for the same reason a <base> tag cannot be used: the document is served at every route depth and base-uri is 'none'. The budget sits below the contract's per-asset ceiling so growth trips a build rather than a refused asset on a phone. esbuild splitting does not lower it: one entry with only static imports emits one chunk (measured). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let React Native Web paint under the shell CSP RN Web 0.21.2 injects its stylesheet at runtime with no nonce support, so style-src 'self' blocks every rule and the page renders unstyled. Measured, not predicted: the render check serves the document under this exact header and reported the violation. 'unsafe-inline' is granted to style-src and nothing else. script-src 'self' holds, which is the directive that decides whether page code can arrive any way other than as a fetched same-origin script. The test now pins that scoping rather than rejecting the token everywhere. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci: prove the Route A app bundle on every PR A dedicated job, for the same reason the browser provider has one: it needs mobile/node_modules and a real browser, and the sharded test matrix would pay for both on every shard. It builds the bundle, verifies it, and runs the builder, override-census and render suites. It ships nothing. The mobile_web_app signal is lifted out of should_run the way static_analysis is. A mobile-only diff is desktop-irrelevant and skips every gated job, and that is exactly the diff that changes the page this job builds. Also the C0.6 review follow-up: mobile/package.json and mobile/pnpm-lock.yaml join the installer cache keys in the two workflows that build an installer off a hashFiles key, since beforePack requires out/mobile-web and a mobile-only change must miss those caches rather than reuse a stale build. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): pin the shipped builder against the app builder's own module name The assertion named a specifier that no longer exists, so it held vacuously. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the RN Web style-src grant in the Swift checks The Swift twin of the Kotlin CSP test still required style-src 'self' and no unsafe-inline anywhere, so it trapped on the approved grant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): make the Route A render check name what each route paints The check asserted only "some html, no errors", which expo-router's Unmatched screen satisfies: pointing HOST_ROUTE at /zzz/not-a-real-prefix stayed green. Each route now asserts content only its own component produces, and the unmatched case asserts the screen positively so the negatives discriminate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): read the shell CSP past the comments that quote directives Both constants document themselves with // comments containing quoted directive text, which the quoted-string scan picked up as directives. One parser now drops comment lines, and iOS and Android go through it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(build): honour a .web.* route sibling in the app bundle Routes were imported by absolute path with the extension, so esbuild's resolveExtensions never applied and a .web.tsx under app/ was dead code the census still accepted. The manifest now carries a key and a module: the key stays the native filename so the URL does not move, and the module is the web sibling when one exists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): tie each named shim to the esbuild option that implements it The shim list was asserted against a literal copy of itself, which passes however the build is configured. Each entry now carries an appliesTo that reads its own option, checked against the real options object. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(build): line up the CRLF exemptions, the budget comment, and the job scope The builder loads .gif as a file but neither .gitattributes nor the CRLF scan exempted it, so the blanket eol=lf pin would have rewritten one. A test now keeps the two lists in step. The Phase C byte budget's comment sat on the asset count, and a root package.json edit could change build:mobile-web:app without running the job that proves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(build): satisfy the index-check lint rule in the CSP parser Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * ci: key the installer caches on the mobile page trees too beforePack builds the mobile web bundle into the installer. Today those bytes are Phase A's, which src/** already covers, but once C1 flips the entry to mobile/app a page-only change would hit a cache holding a stale installer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): skip the bundling tests where mobile dependencies are absent The sharded `test` job collects config/scripts/**/*.test.mjs and installs no mobile dependencies, so the two new suites failed there on "Could not resolve react-native-web". They now skip themselves with a message naming the job that runs them, and that job sets ORCA_MOBILE_WEB_APP_DEPS_REQUIRED so a missing install fails it instead of skipping everything it exists to prove. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(build): scan mobile/packages in the .web.* census The census claimed the app entry never resolves into packages/, but the dictation hook imports @orca/expo-two-way-audio and the built script carries ExpoTwoWayAudioModule.web.ts. That file is now listed with its reason, and planting a .web.* in each scanned tree proves the scan is not passing because a tree happens to be empty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): assert the route exclusions against a tree that has them mobile/app holds no test, spec or +api file, so the exclusion rule was asserted against a tree it could not fire on. A scratch tree plants one of each; dropping the rule now fails this test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): 404 unknown file paths in the render check's page server The server answered every path with the document, so pointing publicPath at /wrong-prefix still rendered three green routes: the script is fetched from the one prefix that is served. A path naming a file now has to come out of the bundle, which is what the shell's manifest map does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): cover the app bundle verifier's own checks The verifier had no test. One doctors the buildId, which the packaged assert catches; the other rewrites the tree so every digest still agrees and only the two fresh builds can tell, which is what a stale out/ looks like. Deleting either check now fails a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(build): tidy the app bundle comments and the job's path prefixes Drops an export nothing read, merges two comments that had drifted apart from the constant they describe, and corrects the claim that the job runs on every PR when it is path-gated. package.json leaves the prefix list because GLOBAL_FORCE_FILES already forces every job on it; mobile/packages/ joins it, since the page resolves a .web.ts out of there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(build): merge the duplicate node:fs/promises import in the census Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): redirect the hybrid shell route on the web page app/h/[hostId]/web.tsx reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import. In a browser that throws before React mounts, and the route manifest imports every route statically, so one native route left the whole page blank at every URL. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): fail the render check with the error that stopped the mount The check waited on "#root has children" with Playwright's animation-frame polling, so a route module that threw at import read as a bare 30s timeout naming nothing. It now waits on a mount attribute the entry sets after the router commits, polls on a timer, and races the wait against the first uncaught error so the failure carries it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): answer the favicon the render browser asks for CI resolves the runner's Google Chrome, which requests /favicon.ico; the bundled headless shell does not. The bundle carries no icon, so the server answers 204 rather than turning a browser habit into a console error the render assertions read as a page fault. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): settle the render check's uncaught-error race without rejecting The entry throws during goto, before anything awaits the race, so a rejected promise surfaced as an unhandled rejection beside the real failure. The same signal now resolves with the error. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): list the page transport in the raw request port inventory The placeholder client implements the port, so the boundary test counts it as an unlisted file. It belongs under OWNERS until C0.4's BridgeRpcClient replaces it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
a98314e8bb | Update README downloads badge | ||
|
|
e359fbf746 | fix(editor): scope currency escape recovery to escaped source | ||
|
|
acf68429e6 | chore(editor): remove diagnostics and satisfy serializer lint | ||
|
|
056c12d29b | fix(editor): preserve currency escapes in tables and formatted text | ||
|
|
47d107cf2e |
feat(mobile): hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) (#21435)
* refactor(mobile): say whether a host status was readable, and carry its protocol window `useHostStatusGates` settled the same closed gates for a host that answered `status.get` with no capabilities and for one whose status nobody could read: both paths produced an empty capability list and an `ok` verdict. A caller that walls on a missing capability cannot tell those apart, and the mobile web bundle's wall is terminal, so it must never fire for the second. `statusReadable` distinguishes them. `hostProtocolWindow` exposes the two protocol numbers the hook already read for `evaluateCompat`, as the reply's own fields, so the bundle wall can evaluate its own window without a second `status.get`. Both are additive; no existing consumer changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): add the hybrid shell flag and the generation path both loaders demand `orca:mobileWebShellEnabled`, default off and unreadable-is-off, in the same shape as the terminal autocomplete flag. `generationDirectoryPath` converts the store's `file://` uri to the absolute path the native shell view requires: both `MobileWebShellGeneration.load` implementations refuse anything without a leading slash, and `expo-file-system` only ever hands out uris. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the hybrid shell session as a pure reducer Every decision the route makes, as `(session, event) -> (state, effects)`: the capability wall, the lazy sweep and cache read, the manifest check, the cached build-id hit that skips paging, the offline open with no compat check, and the three recovery rules the native shell view's contract states. Pure, so the rules are table tests rather than a simulator run. Two latches sit beside the state because both outlive it: `retriedOnce` spans the delete and refetch that returns to `checking`, and `remountedOnce` spans a `ready` replaced by a `ready` under a new session id. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) Wires the four Phase B and A pieces together and adds no decision of its own. `h/[hostId]/web` sits inside the existing `HostProtocolGate` tree, so the native `desktop-too-old` wall still applies above it. With the flag off — every store build, since the only writer is a `__DEV__` Troubleshoot toggle — the route redirects to `h/[hostId]` and the screen is never constructed, so nothing is fetched, written or swept. The runner owns only the impure edges and checks an epoch before every dispatch, so an unmount, a host change or a retry abandons work in flight and aborts a download that would otherwise hold four of the host's read slots. The native view is keyed on the session id, which is what makes the reducer's remount a rebuilt WebView with every fence reinstalled. A census test pins who touches the flag: the route reads it, the developer row reads and writes it, and the key itself lives in one module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the hoisted test doubles instead of asserting them The changed-code casting gate refuses `as` in new code, and these three were only widening an empty literal. An annotated `vi.hoisted` factory does the same job under a check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a scheduled reconnect as an unreachable host, not a dial in progress Found on a simulator with the paired desktop stopped: the client never settles on `disconnected`. It dials, fails, schedules a retry, and cycles `connecting` -> `reconnecting` -> `connecting` with the delay growing to a minute. Mapping `reconnecting` to "still connecting" left a phone holding a verified cached generation on `checking` forever instead of opening it, which is the one case the offline rule exists for. `connecting` and `handshaking` are the first dial and still wait; everything else is unreachable. The mapping moves next to the reducer it feeds, because it is a decision and the runner is supposed to hold none. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build the reachability stub instead of asserting it Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the shell session vocabulary into its own module Pure move, no behaviour: the states, events, effects and gates the reducer and its runner share now sit beside the reducer rather than inside it, so the transition rules have room to grow under the file's line budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop a shell effect result whose flow has been superseded Every restart of the flow bumps a number the effects of that run are stamped with, and a result echoes it back: a manifest read still in flight when the socket drops used to reject after the offline path had already opened the cached generation, replacing a displayed workspace with a download failure, and a status refetch arriving mid-check used to run the cache read and the download twice. The gates restart no longer clears the remount latch either; only the retry button does, so a reconnect cannot grant a second remount. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the flag across modules, not just src and app The native view tree was outside the scan, so a reader added there would have passed an assertion that reads as exhaustive. Proven by adding one to the shell view module: the census fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let only the state that mounted the view hear the view A native batch reports two failures in a row, and the reducer applied both: document-load-failed started the delete-and-refetch, render-process-gone then made it terminal without a new flow, and the cache read the recovery had already asked for dragged the session back to checking behind a failure screen. A report arriving outside `ready` is from a view that is no longer on screen, so it changes nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say a host status could not be read instead of spinning on it A transient status.get failure settles the gate unreadable and nothing probes it again, so the route sat on "Checking host" for as long as anyone left it there and Try again re-read the same settled answer. It now says what happened and offers no retry, and a status that does become readable picks the flow back up on its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restart the flow on the verdict that changed, not on every gates object A reconnect cycle rebuilds the gates several times a second with the same answer in them, and each one re-swept the staging tree and flipped an offline screen to a spinner and back. Only a changed verdict restarts now, which is also why the gates effect has to depend on the host id: two hosts whose gates read identically would otherwise leave the second session in `checking`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover what only the shell runner can get wrong Three cancellations had no test: the epoch that stops a result reaching a session that is gone, the unmount cleanup that aborts the download, and the retry that does both before starting over. Each is now red under its own mutant. The download also re-checks the abort before it writes, since an abort landing between the fetch's last read and the commit would otherwise still put a generation on disk for a screen nobody is on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the shell runner's refs after the commit, not during render React can replay or discard a render, so a handle written during one can run effects for a session that never existed. The client and the host cache key stop being refs at all; the effect handle is committed in an effect above every effect that dispatches. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the hybrid shell flag unreadable outside a development build Development and release share a bundle id, and the iOS data container survives an install-over, so a flag a developer toggled on would follow the store build in and mount the shell on a deep link. The release read never reaches storage. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drive the route's flag read as each build kind reads it The route test exercises the real preference read, so it has to say which build it is. A store build whose container kept a development toggle redirects. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a cache read that lands mid-dial wait for the compat check A connection still being made is not a host that cannot be reached. Opening the cached generation there skips the compat check the landing connection is what makes answerable, so only `unreachable` takes the offline path now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): move the developer toggle only after its write lands The route reads the flag back from storage, so a switch that moved on the tap let the open button race the value that was being persisted. The switch and the button both stay put until the write settles, and a failed write keeps the previous position. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): say which build kind a test runs as without asserting on globalThis Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): undo a staged generation the abort reached before the commit The commit is the write staging cannot take back: it renames into the active slot and moves the host index. An abort landing while the bytes were being staged now removes the staged tree instead of activating it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): open the cached workspace when the link, not the bundle, cut a read short An RPC rejection can reach the reducer before the reachability change does, so the offline gate never fires and a phone holding a valid generation reads that the workspace could not be downloaded. A read that failed on the link now opens what is on disk, the same path offline takes; a verdict about the bundle, from the host or from the bytes, still fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send a hybrid shell recovery through the same gate a start takes A view failure deleted the host cache and went straight back to the manifest check on whatever gates the ready session happened to be holding. Gates that arrive while a generation is on screen are stored without restarting, so after a reconnect whose status probe failed a ready session carried statusReadable false and an empty capability list, and the recovery's manifest check walled the host as bundle-unavailable: terminal, no retry, about a host that never answered. The gate is now one verdict both entries read, and recovery passes its delete through it, so an unreadable status lands on the status-unreadable message that re-arms when a readable gate arrives, and only a readable refusal still walls. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
42a783fe56 | fix(editor): keep plain money escapes out of math recovery | ||
|
|
5874c40334 | fix(editor): admit comments inside image alt text | ||
|
|
b44a5b8d9e | chore(editor): satisfy raw destination lint | ||
|
|
7bdaa7056e | fix(editor): retain raw link and image destinations | ||
|
|
cdcb4bffab | chore(editor): remove unused escape helper | ||
|
|
7f51afb5e4 | fix(editor): keep table and display math dollar escapes | ||
|
|
b9733a805b | fix(editor): normalize plain escaped money text | ||
|
|
30858984e0 | fix(editor): preserve escaped mark serialization in plain blocks | ||
|
|
e26bbbb62f | fix(editor): retain balanced markdown destinations | ||
|
|
8eb45fe4ef | fix(editor): force source serialization for escaped entity marks |