mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
eecd30bf0e3d60cd6e987fae5dc5bc119ad5aee8
11320
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
eecd30bf0e | fix: report missing Antigravity hook scripts | ||
|
|
49a85bfad5 | fix: refuse Antigravity readiness while host is unverifiable | ||
|
|
dfbb928816 | fix: use current Antigravity screens for waits and delivery | ||
|
|
9fc2c5bea6 | fix(antigravity): validate the visible composer before resolving adopted waits | ||
|
|
cae60dafc2 | fix(antigravity): gate configured models on remote runtime support | ||
|
|
a94b345313 | fix(antigravity): discover current source-control models and use CLI defaults | ||
|
|
7063c2cbdd |
fix: read crash diagnostics without loading whole dumps into memory
Read crash diagnostics incrementally to avoid whole-dump memory spikes. |
||
|
|
8812534335 |
fix(claude): stream transcript ancestry proofs
Stream transcript ancestry proofs without loading whole histories into memory. |
||
|
|
d09752854a | Update README downloads badge | ||
|
|
428558b941 |
fix(mobile): let the shell's page paint a file preview (OTA phase C, C3.0) (#21591)
* fix(mobile): let the shell's page paint a file preview A file preview has one shape on the wire: the desktop answers a base64 body and `normalizeMobileFilePreviewResult` composes `data:<mime>;base64,<content>` for React Native Web's `Image`. Under `img-src 'self'` the browser refuses to load it, so every image preview in the page paints nothing — reproduced in the render check, which logged the refusal naming `img-src 'self'` before this. `data:` is granted to images and to nothing else, so what it admits is what the page itself composed out of a reply it already holds; `script-src 'self'` and `connect-src 'self'` are untouched, and `blob:` is not added because nothing in the closure needs one. Both platform pins narrow from "the header contains no `data:`" to "`data:` appears on `img-src` and nowhere else", which is the check that still fails if a later directive grows one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say what the data: image case actually loads through Round 1 is right on both counts. In react-native-web 0.21.2 the hidden <img> the Image component renders carries `alt`, `style`, `draggable`, `ref` and `src` and no load handlers at all — it is there for the browser's image context menu and for `getBackgroundSize()`. The load signal comes from `ImageLoader.load`, which is `new window.Image()` with `onload`/`onerror` on it, so the `new Image()` in this case is the same mechanism the screen's own load runs through rather than a stand-in for it. And the screen maps `onImageError` to "Unable to load preview" (`MobileFilePreviewScreen.tsx:282`); "Binary preview unavailable" is the normalizer's `binary_file` branch, which a CSP refusal never reaches. Comment only. No assertion moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the img-src data: bound as the destination, not provenance "admits only what the page itself built" read as a provenance guarantee, and CSP has none to give: `data:` is matched as a scheme, so the directive admits any `data:` image URL and the browser cannot tell one the page composed from one it was handed. Nor is the content the page's own — the mime type and the base64 body both come from the host, and `normalizeImagePreviewResult` only checks the mime type is a non-empty string. The true bound is where the URL goes: it is never fetched as anything but an image, `img-src` is the only directive admitting it, an image fetch executes nothing (an SVG inside an `<img>` runs no script), and `script-src 'self'`, `connect-src 'self'` and `object-src 'none'` are untouched. Both copies reworded identically, since they are kept in step by the render check's own policy comparison. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d966927013 |
fix(omp): submit large prompts in one PTY frame (#21573)
* fix(omp): join prompt submit with large paste * test(omp): cover joined submit timing * ci: rerun PR checks after timing test fix * test(omp): acknowledge joined submit activity |
||
|
|
e8a956e833 |
fix(remote): reassert terminal geometry after reveal (#21568)
* fix(remote): reassert terminal geometry after reveal * test(remote): expose layout queues through fixture * test(remote): narrow layout target assertion |
||
|
|
2bf538a4e1 |
fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see (#21436)
* fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see A local terminal-artifact grant pinned the file as `dev:ino:nlink:size:mtimeMs`. On Linux every one of those can survive an unlink+recreate: ext4 reuses the just-freed inode (measured: 100% of the time), nlink and size are unchanged for a same-size replacement, and the mtime clock is tick-quantized to 1ms, so a swap inside one tick produces a byte-identical identity string. The grant then served the attacker's bytes as if nothing had changed. Local grants now also pin a sha256 of the artifact's content, taken from the same handle as the stat so nothing can swap the file between them, and every local read, preview and write re-checks it before returning or committing content. The stat identity string itself is unchanged: the relay recomputes it verbatim to honour `expectedStatIdentity`, so its format is a wire contract. Remote grants keep the stat-only check and are untouched. This is also the mechanism behind the intermittent `orca-runtime-files-terminal-artifact-io.test.ts` failure on `rejects stale absolute terminal artifact previews before returning changed content`: it replaces an 8-byte artifact with 8 different bytes, so whenever the two writes shared a 1ms tick the product genuinely could not tell them apart. * docs(runtime): record what the terminal artifact grant checks do not close The digest makes the same-size swap detectable; it does not make the sequence atomic. A reader arriving at the access module would reasonably assume otherwise, so write down the measured limits of the stat identity, why the identity string cannot change, and the four windows that stay open — the write path's surviving rename() gap above all. |
||
|
|
edd9e3125b |
fix(mobile): give the page its height and its long press; C1.7 device proof (OTA phase C, C1.9) (#21589)
* fix(mobile): give the Route A document the height its mounted tree measures against (OTA phase C, C1.9) The document this builder emits carries no stylesheet, so `html`, `body` and `#root` have no height, and every box react-native-web lays out below the mount is `flex: 1` against a parent that measures 0. The collapse is silent in every check that existed: the entry stamps `mounted`, the route tree commits, `innerText` holds every row, and the accessibility tree reports each one at the offset it would have had. Nothing is painted below the header, and nothing takes a tap — the list sits inside a scroller the collapse clipped, and a phone reads it to VoiceOver while no row responds. Lane C1.7 found it on both an iPhone 17 Pro simulator and a Pixel 9 Pro emulator, and the same bytes reproduce it in headless Chromium. The fix is the reset Expo's own web template ships for a react-native-web root, emitted inline because the shell's CSP already allows `style-src 'unsafe-inline'` for the sheet react-native-web injects at runtime; a linked asset would paint the collapsed layout until it landed. The render check gains the assertion that would have caught it: the root's box measured against the viewport, and the one control this route paints with no RPC answered — the New Workspace button, positioned against the bottom of the root, which the collapse moved to y = -72 — asked for by `elementFromPoint` at its own centre. Laid out is not reachable, so the check is a hit test and not another read of the DOM. Without the reset it fails `expected +0 to be 844`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the page have the long press WebKit was taking (OTA phase C, C1.9) The shell's WKWebView is built with the default text interaction, so WebKit installs its selection assistant over the page. A hold on a worktree row raises the selection loupe over the row's own text and the touch is cancelled before the page's responder sees it, which leaves every long-press action in the page dead on iOS while a tap works. Lane C1.7 measured it: the same injected hold opens the row action sheet on the native list and on the page in the Android WebView, and does nothing in the page on iOS. It is not the document's to fix, which the device disproved one rule at a time: `-webkit-touch-callout: none`, `-webkit-user-select: none`, and both together all left the loupe and left the hold undelivered, and headless Chromium confirms the property computes to `none` on the page's text, so the CSS reaches it and WebKit's own gesture wins anyway. The cost is real and named here rather than discovered later: the page has no text selection on iOS, so selectable `Text` — markdown, diff rows, file preview, chat — cannot be selected there until a page-side copy affordance exists. Everything the shell already forbids is unchanged, and Android is untouched. No unit test: the module's Swift checks compile the seven WebKit-free logic files and never import WebKit, so a `WKWebViewConfiguration` cannot be built in them. The device proof stands in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the root reset the template's bytes, not a copy with an addition The comment said the reset is what Expo's web template ships, and `margin:0` was not in it. `@expo/cli@55.0.36/static/template/index.html` carries height, `overflow` and the root's flex box and nothing else, and react-native-web emits `body{margin:0}` in the sheet it injects at runtime, so the addition only covered the frames before that sheet landed. Nothing pinned it either: removing it left all 60 tests green, which is the other way of saying it was never load-bearing. Dropping it makes the string one thing with one source instead of a copy to keep in step with two. The pins on the rest of the reset are unchanged, and so is the frame that mattered: the root still has a definite height before the first paint, which is what the collapse needed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the render check with the root formatter `config/scripts` is formatted by the root oxfmt, not mobile's, and CI checks neither, so a 102-char line I added sat over the root's `printWidth: 100` with nothing to catch it. Reflowed by `./node_modules/.bin/oxfmt --write` from the repo root; no behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say what the root reset shares with Expo's template, not that it is its bytes "The bytes Expo's web template ships" is false and checkable: the template's own block is pretty-printed with comments and trailing semicolons at 410 bytes, and this string is 112. What is actually true, and what the next reader needs, is that it carries the same declaration set and the same `id="expo-reset"`, minified. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin every rule of the root reset, not two substrings of itself The check read the constant back against itself: `toContain(MOBILE_WEB_APP_ROOT_RESET)` plus two substrings taken off that same constant. A rule dropped from it took the assertion with it, so `body{overflow:hidden}`, `flex:1` and the `expo-reset` id were unpinned — and the render check stays green without the overflow rule, so nothing else held them either. Each rule is now a literal written here, named one at a time so a failure says which one went, and the id is pinned beside them. Verified red-first: removing the overflow rule, the `flex:1`, or the id each fails this test and only this test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d35e1dcba8 |
ci: route pane close and retirement changes to the close specs (#21572)
Every terminal-pane route named what BINDS a pane — the pty transports, the ssh reconnect ledgers, the park watchers. None named what unbinds one. #21005 changed the pane close and retirement lifecycle across three transports, and replaying its fifteen paths through the selector returns [] with --reusable-workflow false: it merged with E2E skipped outright. #21001, the same seam a week earlier, ran E2E only because it happened to also touch an ssh-named file, so the close specs were never selected even then. Unbinding is the half that strands a PTY or leaves a retired leaf mounted as a blank pane, so the three specs that judge it now gate it: the parked-tab close retirement, the split-pane close layout consistency, and the paired client's view of a leaf the host retired. Scope held deliberately narrow. The route is not an SSH source route, so it does not start a Docker relay; close reaches SSH only through the shared provider the non-Docker specs already cover. runtime-rpc-client.ts is left out although #21005 touched it: it carries no close decision and churns about three times as often as these files, so routing on it would run this lane on unrelated runtime work. Replaying twenty merged PRs shows exactly one selection change, #21001. |
||
|
|
2a53293b11 |
test(e2e): stop a spec's parking-delay override from leaking into the rest of its worker (#21571)
* test(e2e): scope the parking-delay override to the spec that needs it
A Playwright worker imports many spec files into one Node process, and the app
fixtures launch Electron with a spread of that process's env. The split-
orientation spec set ORCA_E2E_TERMINAL_PARKING_DELAY_MS at module scope, so the
2s override outlived the file and reconfigured every app launched by every spec
that followed it in the same worker — measured directly: a probe spec sees a
30000ms cold-park delay on its own and 2000ms when that spec runs first.
Module scope is the part that cannot be undone. A write inside a test body can
save and restore, as four other specs here do; a write at import time runs
before any hook exists to restore it. test.use({ orcaAppExtraEnv }) reaches the
app launch without touching the worker every other spec shares.
The ratchet holds the module-scope writer count at zero.
* test(e2e): re-apply screen-reader mode while reading the accessibility tree
Separate from the env leak above, and unproven against the CI failure it
resembles: this is robustness, not a diagnosed fix.
screenReaderMode is an option on the xterm instance and the accessibility tree
belongs to that instance's DOM. The SSH cold-activation spec set it once,
imperatively, then waited on the node. A pane that parks and remounts, or
rebinds after a reconnect, comes back as a new instance with the option off, so
the one-shot mutation stops producing the node the wait is waiting for and the
wait reports "element(s) not found" rather than a content mismatch.
The helper re-applies the option inside the poll and returns null when the node
is absent, so a replaced instance is retried instead of being fatal. Five other
specs still use the one-shot pattern and are left alone.
|
||
|
|
1ef947394b |
feat(mobile): hand the page's dead Back button to the shell (OTA phase C, C2.2) (#21582)
* feat(mobile): answer navigate-back on the shell side of the bridge (OTA phase C, C2.2)
A page served at `/` holds the one history entry its entry wrote with
`replaceState`, so `history.back()` goes nowhere and a page Back button is
dead. The only stack with somewhere to go is the native one the shell pushed
the page onto.
Adds `notify { name: 'navigate-back' }` to the closed client union, gated on
the existing `navigate` grant rather than a name of its own: an app that can
open a screen can close one, and a new grant name would leave every route
declaring it native on every shell already shipped. `MOBILE_WEB_SHELL_GRANTS`
is unchanged and `BRIDGE_PROTOCOL_VERSION` is not bumped.
`bridgeNotifyRefusal` grows a name-to-grant table, since this is the first
notify whose name is not its grant's. The shell screen pops its own stack and
answers false when there is nothing left, which the host logs as
`navigate-back-refused` — nothing crosses back to the page either way, so
silence there is indistinguishable from a Back button that worked.
Inert until a consumer exists: no page posts the name yet.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): hand the page's dead Back button to the shell (OTA phase C, C2.2)
`useRouteHandoff` wrapped `push`, `replace` and `dismissTo` and left `back`
as expo-router's own, which inside the page pops a history of one and does
nothing. It now pops locally when the document grew a stack of its own, and
otherwise posts `navigate-back` for the shell to pop the native stack.
This is what makes the Tasks header's `onPress={() => router.back()}`
(`src/tasks/mobile-tasks-screen-chrome.tsx`) work once the C2.1 consumer
routes that screen's router through this seam; the barrel still reaches
expo-router directly, so nothing calls this yet.
A shell that granted no `navigate` falls through to the local router rather
than throwing out of a tap handler. A shell that granted `navigate` but is
too old to know the verb refuses the frame as `unrecognised-message` and
logs it; neither is distinguishable from the page, and the fallback goes
nowhere in both — which is exactly where Back already went.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): count back among the members that leave the page document
The header said three wrapped members driven by one answer. `back` is a
fourth, and it is not driven by that answer: it carries no target, so the
document's own stack decides it rather than the shell's route list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): complete the bridge hook probe in its two local literals
`Probe` grew `backPops`, and two cases build the object inline rather than
through `mount`. `tsc -p tsconfig.json` excludes test files, so only the
tests-typecheck ratchet saw it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): measure the stack the page Back button rests on (OTA phase C, C2.2)
`useRouteHandoff().back()` asks expo-router's `canGoBack()` whether this
document can serve a back itself, and that answer is React Navigation's, so
no unit test settles it. The render check now measures it in the browser it
actually runs in.
Read through `router.back()` on `/h/[hostId]/edit`, a real route of this tree
whose chevron is expo-router's own back, because the page exposes no handle
to call `canGoBack()` on and a global added for a test would ship forever.
Measured: the router has nowhere to go on the document the shell opens, and a
location change does not give it one either. So the handoff's `canGoBack()`
gate answers false for everything the shell or the browser can do to the
page, and its local branch belongs to a push the page makes through the
handoff itself.
The shell double now records every notify the page posts and takes the grant
list as a parameter, so a control that handed something to the shell can be
told from one that did nothing. The first case asserts a real tap crossing
the bridge, which is what makes the two absences after it evidence.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse a second stack pop while the first is still queued (OTA phase C, C2.2)
`canGoBack()` and `back()` disagree about time. The first reads the committed
navigation state; the second only adds `GO_BACK` to expo-router's
`routingQueue`, which `useImperativeApiEmitter` drains from an effect. Two
`navigate-back` frames delivered in one native batch therefore both read the
stack the first pop has not left yet, both queue, and a three-deep stack
unwinds past the screen the page was opened over. The host forwards every
notify it is granted, so nothing upstream coalesced them.
`useShellStackPop` owns the pop and latches it. The latch clears on the
committed route rather than on a timer, because that commit is the first
moment `canGoBack()` answers for the stack the pop actually left; a pop that
takes this screen off the stack unmounts it and takes the ref with it.
`onNavigateBack` now answers `popped` / `nothing-to-pop` / `pop-pending` so
the `navigate-back-refused` diagnostic is true for the frame it names, and
the log dedupes per reason rather than burying the second behind the first.
Driven against expo-router 55.0.18's own `global-state/routing.js`,
evaluated verbatim with only its externals stubbed: a mock of `canGoBack`
is what hid this.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the notify grant table total over the union (OTA phase C, C2.2)
The table was `Record<string, string | undefined>` indexed with a plain
string, so a notify name with no row returned undefined, read as ungated,
and the host acted on a frame it had never granted. Adding a member to the
envelope's notify union raised no error anywhere — the hole was silent.
Keyed on `Extract<BridgeClientMessage, { type: 'notify' }>['name']` with
`string | null` values, an omitted row is now a TS2741 on the table itself,
and `bridgeNotifyRefusal` cannot be asked about a name the table has no row
for.
Adds the `navigate` and `storage` rows, which were missing: the host was
enforcing the navigate grant for `navigate-back` but not for `navigate`.
Both are inert while every page is offered every grant, and load-bearing the
moment a grant is per-route.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): assert no page errors in the browser case that drives popstate (OTA phase C, C2.2)
The case that dispatches a synthetic `popstate` read its answer as "the page
did not move", and a throw under the page's fault boundary leaves the page
exactly there. Without the errors assertion the other two cases carry, that
absence was not evidence of what it claimed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the routing module's members instead of asserting them
The casting gate refuses the two assertions the loader used, and it is
right: a stub that stopped covering an import would have left the members
undefined and the test would have driven a half-evaluated module. Destructure
and check instead, so that case says so.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the two pops the stack latch never hears about (OTA phase C, C2.2)
The comment read as if the clear were exhaustive. It is not: a pop landing on
an equal pathname does not transition `usePathname`, and a `GO_BACK` the queue
discards commits no route at all — `routingQueue.run` shifts every action off
the queue whether or not `ref.current` is set.
Kept the pathname clear rather than moving to the navigator's `state` event.
The event would cover the first stick: `@react-navigation/core` 7.17.2 emits
`state` from an effect keyed on the navigator state object, and every pop
replaces it. It would not cover the second, which changes no state. And the
emitter is the navigator, not the routing module this hook is written and
tested against, so the switch cannot be earned by a test here the way the
queue behaviour was — it would rest on a mock of the signal under test.
Both sticks are bounded instead, in the commit that makes the latch one per
stack: the holder releases on unmount, so a stick lasts at most as long as the
screen that took it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hold one pending stack pop per stack, not per screen (OTA phase C, C2.2)
`MobileWebShellScreen` mounts at both `app/h/[hostId]/index.tsx` and
`app/h/[hostId]/web.tsx`, and `/h/a/web` is deep-linkable over `/h/a`, so two
shells can be mounted over one native stack. A latch per screen left each of
them holding its own, and two frames from two pages still unwound two screens.
The latch is module-scoped and carries which screen took it, so a shell whose
own route commits cannot release a pop another shell is still waiting on. The
holder also releases on unmount, which is what bounds the two pops the
pathname clear never hears about: a latch nobody is left to release would
outlive the stack it guards and leave Back dead for the session.
Both screen suites now unmount their trees between cases, because a tree that
is only dropped is a screen still holding whatever pop it took.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the gated-notify name list nothing reads (OTA phase C, C2.2)
`BRIDGE_GRANT_GATED_NOTIFY_NAMES` existed so a caller could ask which names
ride a grant. Once the table became total over the notify union, the table
itself answers that and the only readers left were the two assertions that
read the list for its own sake.
Deleted with them. The behaviour they stood next to is kept: the protocol's
own names are still asserted ungated through `bridgeNotifyRefusal`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the routing-module guard actually catches (OTA phase C, C2.2)
The comment claimed the guard catches a stub that stopped covering an import.
It does not: evaluating the module with every stub dropped still defines all
three exports, because the module assigns them whatever its imports resolved
to, and the failure surfaces later as `TypeError: Cannot read properties of
undefined (reading 'navigationRef')`.
What the guard does catch is an expo-router upgrade that renames or removes
one of the three members this test drives.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): keep the second shell mounted across the holder's removal (OTA phase C, C2.2)
The harness swapped the tree's root element between a single shell, a
fragment of two, and a single shell again. Each swap remounts everything
under it, so the callbacks the cases held belonged to unmounted hooks. One of
those could still take the module-scoped latch, and the instance that took it
was already gone, so nothing was left to release it — the last case in the
file leaked the latch into whatever ran next.
The root is now one component with a slot per shell, so removing the holder
leaves the second shell's instance alone, and every pop is taken through a
callback re-read at call time.
`afterEach` now asserts the latch is clear by mounting a screen after every
other one is gone and requiring it to pop. Without it this leak was invisible:
it surfaces only in a following case, and the case that caused it was last.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
5a208ce6fe |
chore(mobile): repin the recording corpus to main's tip after #21566 (#21588)
#21566 re-recorded the corpus with `baseline` set to its own branch commit `47d24d324e`, which the squash merge left unreachable from main. The pin guard on the push to main fails, and so does the pin job on every open pull request, since the merge ref cannot reach that commit either. Repin to main's tip `889c2b562f` and re-record from that tree. Every golden's body is byte-identical to the one #21566 recorded; only the `baseline` header line moves (788 files, one line each). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
889c2b562f |
feat(mobile): say why a host is unreachable instead of "Connecting via Relay…" (#21566)
* feat(mobile): say why a host is unreachable instead of "Connecting via Relay…"
The home-screen host row and host header showed "Connecting via Relay…" for
as long as the desktop stayed unreachable, even when every relay dial had
ended with the cell's 4404 host-offline close. A user's diagnostics export
showed 25 such dials over 25 hours behind that label, and the diagnostics
report itself said "No single failure cause" because relay dial failures
were not recognised and every app resume emptied the evidence window.
Relay close codes now map to a closed RelayHostReachability verdict
(signed-out, host-offline, credential-refused, unreachable), latched after
two consecutive identical dial failures and cleared only by an authenticated
session. The existing signed-out close reason becomes a member of the same
verdict instead of a parallel boolean. classifyConnection renders each
verdict as a label plus a detail line ("Host 1 is offline" / "Check it's
awake, Orca is running, and you're signed in").
Relay dial failures carry their close code as a structured field on the
connection log entry, so the diagnostics analysis names the cause without
parsing error text, and an app resume no longer hides the last failure: it
is reported with a "Before the app last resumed" qualifier and is never a
sendable incident.
* test(mobile): re-record RPC goldens at the new baseline
Only header lines change: the baseline pin on every golden and the
adapterSha256 on the twelve goldens whose mount adapters gained the
getRelayHostReachability context method. No checkpoint moved, which also
shows the commits between the old and new baseline changed no observed
RPC behaviour.
* fix(mobile): tell a refused relay credential to re-pair, not to find the same network
A direct session also rotates the credential, but telling the user to
connect on the same network once explains the mechanism instead of giving
an action, and re-pairing is the one remedy that works from anywhere.
* fix(mobile): let the newest relay failure win the diagnosis, and name the real stale boundary
Relay-path evidence still outranks a newer direct timeout, but among relay
failures the newest now wins: an older 4404 verdict no longer hides a newer
session close (which was also the sendable incident) or a director refusal.
The stale prefix names a network change when that, not a resume, was the
boundary.
|
||
|
|
e49b3aa0bd |
fix(relay): carry the signed-out reason when the broker's own renewal notices the session loss (#21562)
The relay control socket closes with the reason `signed-out` so the cell can tell paired phones to sign in rather than reporting a bare "host offline". Only the auth coordinator computed that reason. The broker's renewal tick called `closeNow()` with no reason when its token refresh came back empty, and `closeNow` is idempotent, so whenever that tick observed the lost session first — the common case for a session revoked or expired while Orca runs — the coordinator's later `closeNow(SIGNED_OUT)` was a no-op and the cause never left the desktop. `refreshAccessToken` now returns a refusal that carries the reason the coordinator already derives, and both close paths read it from one place. A refusal for any other cause still names nothing: a present-but-unentitled desktop is signed in, and a superseded refresh leaves the close to whoever superseded it. |
||
|
|
289a8bf1ce |
feat(settings): confirm before stopping running terminals (#21569)
* feat(settings): confirm before stopping running terminals * fix(settings): localize close confirmation search keywords * ci: rerun PR checks after localization fix * fix(settings): add search keyword translations |
||
|
|
b6f6122bcd | fix(terminal): preserve idle Linux IME direct commits (#21567) | ||
|
|
b69bc6d5b1 | fix(terminal): preserve escape tails in renderer snapshots (#21578) | ||
|
|
c34b944136 |
feat(github): bind projects to a specific gh account (#13664)
* feat(github): bind projects to a specific gh account Adds per-project `Repo.ghAccount` so repo-scoped gh calls (create-worktree issue/PR search, work items, hosted-review reads and mutations) run as the bound account via ephemeral child-env token injection instead of the globally active gh login. Multi-account resolution is capability-gated (gh >= 2.40) and fails closed when the bound account or host is unavailable; Project View stays ambient by design. Repository settings gains a section for selecting or clearing a keyring-backed account (shadcn `Select`), with mixed-version "not enforced" handling for older remote runtimes. Attached `-Rhost/owner/repo` forms are covered by the host-drift guard and its tests; es/ja/ko/zh catalogs carry the section's strings. `getLocalProjectGhExecOptions` centralizes the binding lookup so every gh execution path picks it up, including the Electron `hostedReview:*` handlers that previously stayed on the ambient login. `gh auth token` (a keyring read) is exempt from the rate-limit breaker gate so a tripped bucket cannot turn a bound-token resolve into a false "unavailable". The `ghAccount` update field and the two binding RPC methods live in the shared RPC params contract; the generated catalog is regenerated. Fixes #13612 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B3QEP5iP4WGGEpPLtkHqA * fix(settings): make GitHub account refresh secondary * fix(github): satisfy strict casting quality checks * test(rpc): use runtime fixture for repo binding * fix(github): preserve project account for PR worktree lookups * test(rpc): avoid incomplete runtime settings fixture * fix(i18n): add GitHub account refresh label * fix(i18n): refresh runtime required catalog * fix(windows): preserve mobile patch bytes --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Neil <neil@stably.ai> |
||
|
|
0817476b2c |
fix(mobile): follow-ups from the C1 stack review, one commit per finding (OTA phase C, C1.8) (#21570)
* fix(mobile): encode the host id the native list hands the shell `web.tsx` encodes the host id into the pathname it opens the shell on; the worktree-list route beside it still interpolated it raw. `useLocalSearchParams` answers the decoded value, so a host id carrying `?`, `#` or whitespace builds a pathname that is no longer one segment. That shape is not refused where it is built. `matchesRoutePattern` splits on `/` alone, so `/h/a?b` reads as the single segment `/h/[hostId]` names and the session starts; the bridge's pathname rule is what refuses it, one `init` later, and the shell turns that refusal into `document-load-failed`. The route ends on a failure screen instead of the native list it already has and was about to render anyway. The fix sits at the interpolation rather than at the pattern or the bridge, because the other two are right: the pathname rule is what a path may be, and the page decodes the segment back when it matches `[hostId]`, so the screen it opens is the same one. A deep link is the way such an id arrives, which is what the sibling route's own test already establishes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): end a route segment at the query, not only at a slash The dot-segment lookahead both route patterns are built from treated `/` and end-of-string as the only things that close a segment. An href may carry a query, so the last segment can also be closed by `?`, and there the lookahead never fired: `/h/..?x`, `/h/%2e%2e?x` and `/h/.?x` all passed `BRIDGE_ROUTE_HREF_PATTERN` while their slash-terminated spellings were refused. The sink is `router.push`, and a URL parser resolves `/h/..?x` to `/?x` exactly as it resolves `/h/../x` to `/x`. That is the climb out of the `/h/` prefix the rule exists to stop, reached through the one punctuation the rule did not treat as a boundary. Fixed in `BRIDGE_ROUTE_SEGMENT_SOURCE`, which is the single place the segment rule is written and the reason the two patterns cannot drift apart. The pathname pattern is unaffected: a `?` fails its character class wherever it appears, so widening the boundary cannot let anything new through there. The existing segment-rule block gains the query-terminated spellings beside the ones it already pins. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say which notifies actually reach the mount-order throw The header said a call before `init` is a mount-order bug and throws, and named `notifyPageFault` as the one exception. Two more never reach that throw: a grant is read off the session, so before `init` there is no grant either, and the `&&` in `navigate` and `storage` short-circuits before `post` can require one. The code is right and the comment was not, so the comment is what changed. False is already these two members' refusal answer — it is what they give a shell that withheld the grant — and both callers handle it. `useRouteHandoff` calls `notifyNavigate` uncaught inside `push` and falls back to routing inside the page, so making this path throw would turn an early tap into an unhandled error in a handler nobody wrapped, which is the same reason the close path answers inertly rather than throwing. Pinned rather than left to the prose: the two gated notifies answer false and post nothing before `init`, the two ungated ones still throw, and the gated ones post once the shell has granted them. Not a red-first test — there is no defect here to reproduce — but the contract now has a test holding it in place. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): re-arm the shell session the route rebuilt, not only the host Two effects share one session. The first rebuilds it from `hostId` and `routePathname`; the second is the only thing that ever tells the reducer what the gates say, and it listed the host alone. A fresh session starts in `checking` and moves on nothing but `gates-changed`, so a route that changed under an unchanged host and unchanged gates threw the old session away and left the new one with no effect to run and no verdict to wait for. `routePathname` joins the gates effect's dependency list, beside the `hostId` that is already there for the same reason: both are what rebuild the session above, so both have to re-arm it. Fixing it in the dependency list rather than by having the reducer restart on a repeat verdict keeps the reducer's rule intact — a repeat verdict genuinely is nothing new — and keeps the coupling stated where the coupling lives. No caller can reach this today: both routes derive the pathname from the host id, so the one cannot change without the other. The test drives the hook directly and holds the invariant the wiring is supposed to have, since the thing protecting it was a property of the call sites and not of this hook. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): persist the last-visited worktree through the mirrored writer `writeLastVisitedWorktree` noted the write on the mirror and then dropped the store's promise with `void`. The mirror reports the key as written the moment it is noted, so a store that refuses the write leaves a value the page is handed on every `init` and that nothing ever persisted, and the rejection escapes as an unhandled one because no caller above it holds a catch. `writeMirroredStorage` in the same module is already exactly this: note first, persist second, and swallow the rejection deliberately, because a pin that failed to persist is not a reason to take the workspace off screen. This writer had grown its own copy of that pair without the last part. Reusing it rather than adding a local `.catch` is what stops the two copies drifting again, and it is the boundary that owns the relationship between the mirror and the store. The test drives a store that refuses the write and listens for an unhandled rejection, which is the failure the `void` produced and the only way to observe it from inside a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): pin the re-arm oracle, and move the query rationale to the rule Two review nits from round 1, neither changing behaviour. The re-arm test asserted the session was no longer `checking`, which a failure state satisfies just as well as a recovery does — the test would have passed on the opposite of what it is for. It now pins `native-route`, which is the state a re-armed session actually settles on here: `/h/host-1/tasks` is not the route the bundle lists, so the reducer answers with the native screen. The sentence about `?` closing a segment sat in the doc block for the `init` pathname bounds, which opens by saying that pathname carries no query. Read top to bottom the block contradicted itself. The rationale belongs beside `BRIDGE_ROUTE_SEGMENT_SOURCE`, where the shared rule is written and where the reason is legible: an href carries a query even though a pathname does not, both are held to the one segment rule, and widening its boundary cannot loosen the pathname pattern because a `?` fails that character class anywhere. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state what the native router really does with a dot segment Round 2 review, comment text only. The rationale beside `BRIDGE_ROUTE_SEGMENT_SOURCE` claimed `/h/..?x` resolves to `/?x`, borrowing the climb `history.replaceState` performs on the `init` pathname. That is the wrong sink. An href's sink is the native router, and expo-router's `resolveHrefStringWithSegments` normalises only an href beginning with `.`; a rooted one is passed through, its query stripped, and the forked `getStateFromPath` then matches segments literally against the route patterns. A dynamic segment compiles to `([^/]+\/)`, which takes `..` as happily as any other value. So the harm is not a climb and it is not Unmatched either: `..` is read as the `[hostId]` a screen is opened for, and the shell opens a host screen for an id no host has. A different wrong screen from the slash-terminated spellings, and the same reason one rule covers both patterns. The boundary and the test that pins it are unchanged; only the sentences describing them are. The notify header opened by saying three of the four share one guard, one paragraph above the one explaining that only two ever reach its throw. It now says both in the same breath. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
030a1e0c77 |
fix(relay): stop holding a cell row across the whole control accept (#21563)
* fix(relay): stop holding a cell row across the whole control accept The cell accept path took the host's cell row FOR UPDATE at its first supersession statement and held it to COMMIT across a dozen round trips, which capped a cell far from Postgres at a couple of accepts a second. Fold every cell-row change on the path into one conditional delta write issued last, so the contended row is held only across the commit. * fix(relay): give relay_cells one global row lock order, taken last Moving the accept's cell-row write to the end of its transaction put it after the host's relay_control_connection_reservations rows, while every director path that reads the inventory took those rows the other way round. Pin one order for both roles -- host rows, then the shared cell row -- by locking the host's reservation rows before the inventory in the nine director paths that take both, document the tiers next to CellInventoryLockMode, and add a census that fails on a new path taking relay_cells first. |
||
|
|
545f526c31 |
refactor(editor): land shared Markdown scanners on main (#21565)
* refactor(editor): introduce shared Markdown code scanners (#21554) * refactor(editor): add shared Markdown scanners * test(editor): verify standalone scanner boundaries * fix(editor): preserve line endings and fenced code boundaries * fix(editor): keep bare dash lines out of table scanning |
||
|
|
2038376d8e |
fix(terminal): a park must not discard the only copy of a remote pane's scrollback (#21285)
* fix(terminal): keep a client copy of a parked remote pane's scrollback
A remote-runtime pty's bytes never transit the client's main process, so the pane's
xterm buffer is the only client-side copy. The ordinary cold-park unmounted that pane
without capturing it, licensed by TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY — a static
build string that says nothing about whether the host retained this pty's buffer. On
reveal, a host that answers 'no-serializable-buffer' (or stays silent past the request
timeout) collapses to a null snapshot and the pane paints blank: tabs and splits survive,
the scrollback is gone.
Capture before every park, not only the retention-budget force-park, so the reveal has a
copy to replay when the host cannot answer. An unverifiable host answer is not proof the
pane was empty; keep the buffer, never discard it.
Adds ORCA_E2E_FORCE_REMOTE_TERMINAL_SNAPSHOT_UNAVAILABLE so an e2e can reproduce the
host-retains-nothing state, mirroring the existing forced-truncation lever.
* test(terminal): prove a parked remote pane survives a host that answers nothing
The oracle is a token the test types into the terminal before the park and the fixture
echoes back. Nothing replays stdin, so a respawned command cannot reproduce that line —
only the pre-park buffer can. An earlier argv marker passed vacuously for exactly that
reason.
The control ('host retains the buffer') is insensitive to the fix and fails if the harness
never parks, never reveals, or never echoed the token, so the regression case cannot be
green for a harness reason.
* refactor(terminal): validate the paired host terminal RPC shape instead of casting it
The merge-commit consistent-type-assertions gate flags every new `as`. Two were fixture
shapes that a type annotation states directly, and the third hid an unchecked RPC payload —
readCreatedTerminalTab now fails with the shape named rather than surfacing later as an
undefined surface id.
* fix(terminal): let a park capture survive an unhydrated repo catalog
Reading state.repos unguarded threw out of the cold-park effect whenever the catalog was
absent, which would break parking itself. Capture is best-effort evidence; an empty catalog
also fails open in shouldPreserveTerminalScrollbackBuffers, the safe direction for a park.
* docs(terminal): pin why the two unhydrated-catalog fallbacks point opposite ways
shouldPreserveTerminalScrollbackBuffers fails open toward 'remote' because a worktree wrongly
judged local parks with no copy at all. worktree-runtime-owner.ts resolves the same unhydrated
catalog to 'local', which is safe there and would be data loss here. A reader pattern-matching
'fail open' across the two gets one of them backwards.
* fix(terminal): keep a parked pane's scrollback across a reconnect merge
The direct-SSH pull replaces a replaced tab's layout wholesale, and a park capture does not
bump tab.generation — so a just-parked tab is not in locallyPreservedTabIds and the only
client-side copy of its remote scrollback went with the layout it replaced. That is the same
data loss this branch already fixes, one layer down, and it is the layer that decides whether
the fix survives the app update the user actually performed.
Carry the client's leaf-keyed scrollback into the host's layout, filtered to the host's own
root leaves. Structure stays the host's verbatim, so a split it added while we were away still
wins and a leaf it retired still drops its bytes. Local wins a conflict: neither copy is then
the only one, but remote-wins would overwrite the tail captured since the last upload and
propagate that backwards on the next replace-session patch.
Not a generation bump: the pane key is `${tab.id}-${tab.generation}`, so bumping would remount
the pane and destroy the very buffer the capture just serialized, lift the recovery-storm
ledger ceiling, and let a stale local ptyId win through preserveNewerLocalTerminalFields.
* fix(terminal): carry a parked pane's scrollback through the mirrored-layout rebuild
Found in review of this PR by rc-ssh-remoting. chooseRemoteTerminalLayout rebuilds a
mirrored tab's layout from the host's picture and never carried buffersByLeafId or
scrollbackRefsByLeafId forward, though it already receives existingLayout. The host
publishes no scrollback of its own, so ANY session-inventory frame landing between park and
reveal dropped the only client-side copy: the rebuild is bufferless, terminalLayoutEqual
compares buffers so the write is not bailed out, and apply-terminal-records assigns it
wholesale.
Measured before the fix: 336 bytes captured at park, 0 after one forced frame, blank pane on
reveal. After: 411 bytes survive the frame and the reveal repaints.
The e2e passed either way because no frame happened to land in its window, so it was not
covering the destroying event. It now forces one inside the park -> reveal window and asserts
the capture survives it.
An identical fix was written and reverted earlier in this branch as 'no measurable effect' —
that measurement ran on a harness deleting the client profile between launches, so nothing
downstream of persistence could register. It was never actually tested.
* feat(session): add a local-only home for ordinary-park scrollback
localOnlyScrollbackByTabId is a top-level session field, tabId -> leafId -> buffer, that never
rides the remote projection: exportRemoteWorkspaceSession is an explicit allowlist of named
top-level fields, so a new one is omitted for free, whereas anything added to
TerminalLayoutSnapshot is copied whole. It is also outside the two records the mirrored-tab apply
rewrites, so a host inventory frame cannot wipe it.
Registered in every exhaustive session registry ('tabKeyed'), hydrated and scoped like the layout
map, dropped with its tab on close/removal/purge/repo removal/mirrored retirement, copied on profile
transfer, emitted by the incremental patch builder, and capped by pruneLocalTerminalScrollbackBuffers
alongside the shared home — with a per-home test so an uncapped path cannot go unnoticed.
Known ceiling, not widened here: the field routes through the partition router that falls back to
'local' when the repo catalog is unknown at write time (#21295).
* fix(terminal): keep ordinary-park scrollback off the upload, and read both homes through one resolver
The ordinary cold park fires on every workspace hide. Its capture now splits: structure (root,
ptyIds, titles) stays in the shared layout, bytes go to localOnlyScrollbackByTabId. Force-park,
hibernate, sleep and shutdown keep writing buffersByLeafId, because that copy is what a second
desktop cold-restores from; a shared capture clears the local copy so the two homes never hold two
versions of one leaf.
resolveLeafScrollbackBuffers is the only read across the two homes (local wins a conflict: it is
the later write by construction). restoreTerminalPaneLayout no longer reads buffersByLeafId
directly, the capture's merge prior comes from the resolver, and the post-replay release covers
both homes.
Measured with the projection at 20 tabs x 2 panes at the per-leaf cap: the shared-layout shape
exports ~22 MiB per replace-session; the local-only shape exports the bufferless baseline.
* test(sync): pin that the mirrored rebuild carries the client scrollback refs
The carry-through added in
|
||
|
|
e2afb5eef9 |
feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) (#21503)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) The worktree list now renders from the desktop's bundle, and which routes do is negotiated rather than decided on one side. The manifest gains `routes: [{ pathname, grants }]`, written from one declared list the builder checks against the tree it bundled, so a declaration naming a screen with no module fails the build instead of reaching a phone as a page that paints Unmatched. The field is additive because the phone reads the manifest loosely and pins no schema version; the desktop's own writer stays `.strict()`, and the stale comment saying there was no additive path is corrected. The shell answers for what it can do. A route the bundle does not list, or lists needing a grant this app does not implement, settles as `native-route` and downloads nothing; so does a desktop that ships no bundle at all, which is the one blocked verdict that is not a wall, because a desktop with no bundle declares no page route and there is no workspace to refuse. The route is answered before the compat verdict for the same reason: a bundle this shell cannot open is not a reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx` mounts the shell when the flag is on and takes the native list back as the fallback, and both routes read the flag through one hook so the census stays the whole census. A tap on a worktree row still opens the native session screen. The page posts `notify { name: 'navigate', href }` behind the `navigate` grant, which is not a convention: `notify` is a closed union, so an older shell refuses the whole frame and the page checks the grant before it posts. The shell pushes the target over the still-mounted view, so Back reveals the page with nothing reloaded. `route-handoff.ts` and its web sibling are the seam, router-shaped so the list's own hook and the recorder's adapter are untouched and no golden moves: the web file wraps the three members that leave the document and hands back any target outside the page routes `init` named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) Three gaps the census named, and the last of them is why the page needed a second grant. `expo-secure-store` is `{}` on web, so the page's own `loadHosts()` answered with an empty array and the list painted "Host not found" over the host the shell had just opened it for. `init` gains an optional `host`, and `host-store.web.ts` serves it: the profile the screens read, with no device token and no public key, because the bridge already carries the connection those would have opened. The native writes it cannot make — pairing, renaming, recency — settle rather than throw, since recency orders a list the page never shows. AsyncStorage's web build is `window.localStorage`, and the page has none worth having: Android turns DOM storage off and on iOS the origin host is the session id, so a pin set in the page was gone on the next remount. The builder aliases the module to a page store whose values are the app's own — `init` primes the allowlisted keys, a write is applied locally and posted over a new `storage` grant, and the app is where it lands. The allowlist is two keys and is the whole fence: everything the app stores shares one namespace, the hybrid shell flag included, so a page that could write any of it could turn the feature on for a build that never offered it. A key outside the list is refused and, crucially, not kept locally either — a pin that looks set and is not is the failure the grant exists to avoid. The bridge host is built only once both have been read, because `init` is answered once per `ready` and carries them: a host that started without them would have to be torn down to carry them, and the list would already have mounted against a host it could not name. `Alert.alert` on a failed host removal is a silent no-op in React Native Web, so inside the page that failure had no surface at all. It routes to the error the list already shows, on both platforms. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-route and navigate refs after the commit Same class again: the last two writes this branch adds join the commit-phase effect, so nothing this hook holds is written while React is rendering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the storage-write ref after the commit The last render-phase ref write in this hook joins the commit-phase effect. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the confirm setter this callback already calls in its deps A `useState` setter is stable, so the identity of the callback is unchanged; the list now says what the body reads. Reported on the line this branch rewrote. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3) `handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left the page and never whether the shell accepted it. Two hrefs the app builds today were posted, answered true and suppressed the local fallback, so the tap did nothing at all: the Connection-log link's object form, which `String` turns into `[object Object]`, and any href carrying a fragment, because the pathname is stripped to match and the whole href is what goes on the wire. Object hrefs now resolve the way the router resolves them, and the string is checked against the envelope's own pattern and cap before it is posted; anything that fails falls through to the local router, which is the policy this module already states. Whether a target names a screen that exists is shape's business no longer, and the comment says C1.7 owns it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a failed action off the whole screen and the page's store honest (OTA phase C, C1.4) Five, from one round of review. A removal that fails no longer writes the identity error: that one is an early return over the header, the list and the overlays, with nothing to dismiss it and nothing left to render the confirm it re-opens. It goes to a dismissible line above the list instead, on both platforms, cleared by the next confirmed refresh. `init` reads the allowlisted keys on every answer rather than capturing them at mount, so a document that reloads inside one mount is primed from after its own writes. The read stays synchronous: the page refuses every member until `init` lands and the golden recorder mounts a screen in the same turn it drains one, so a promise here moves the first render of every bridged replay. A profile read that rejects is now a shell failure with a diagnostic instead of a `ready` session with no host behind it and a page asking forever. The page bounds a value by the envelope's own constant rather than caching what the wire drops. And a write is held to the keys this page was handed, so one host's page cannot rewrite another's pinned list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): state the two test fixtures' types instead of asserting them The casting gate reads a `SAFETY:` rationale off the line directly above the assertion, and a wrapped comment puts a comment there instead. Two of the four were not assertions worth keeping at all: a hoisted fixture says its own type, and the router comes from the mock the file already installs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the two fixtures in place, not through a widened binding `const x: unknown = …` followed by `x as T` is the widen-then-assert the anti-slop gate refuses, and rightly: the evidence is discarded and then invented again. The assertion belongs at the literal, with its rationale on the line above it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name routes in the manifest field list the builder emits C1.3 added `routes` to every manifest this builder writes, and the Phase A contract test still listed eight keys, which is what went red in CI. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold a navigate target to the same segment rule as the shell's The href pattern is built from the segment source C1.2 tightened, and nothing said so: a spelling one pattern refused while the other took it would be a hole with a `notify` already pointed at it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the key a refused page write reached for The last diagnostic still falling through to the line about a view outliving its host, on the branch that added it. The key is the evidence: it says which host's pinned list the page was reaching into. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe the navigations this branch added C1.1's new case builds its own probe, and on this branch a probe also collects the hrefs the page hands back. The file stopped typechecking on the merge, which the tests ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe this branch's storage writes too Same merge, one branch further: a probe here also collects what the page asked the screen to write. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the page the app's storage as it stands, not one init late The page is handed its allowlisted keys on every `init`, built synchronously, and the app writes two of them from its own native screens. The shell's map was only re-read after a ready, so a native write between two readies reached the init after next: the drawer opened on the repo the user left. The map is now module-scoped and every writer of an allowlisted key notes it as it writes, so the init that answers a ready already carries it. The store read only seats the map, and a read that started before a write no longer puts the older value back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): find the banner's dismiss without an assertion Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep the storage mirror in the storage tree The app's own writers had to reach into `src/mobile-web-shell/` to note a write, which is the dependency the wrong way round: the shell is what is built on the app's storage, not the other way. The mirror moves to `src/storage/` and no longer knows which keys the page is allowed; the caller names them on every read and every seat, so the allowlist stays where it is enforced. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
56e5cd5e34 | fix(editor): render CJK-adjacent emphasis in Markdown preview | ||
|
|
d8cfe374a3 |
refactor(usage): share attribution and scope filtering
Readiness checklist review: PASS. No proven release-blocking findings. Codex and usage tests: 116 passed; all required CI checks green. |
||
|
|
ac4dc6599b |
feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) The worktree list now renders from the desktop's bundle, and which routes do is negotiated rather than decided on one side. The manifest gains `routes: [{ pathname, grants }]`, written from one declared list the builder checks against the tree it bundled, so a declaration naming a screen with no module fails the build instead of reaching a phone as a page that paints Unmatched. The field is additive because the phone reads the manifest loosely and pins no schema version; the desktop's own writer stays `.strict()`, and the stale comment saying there was no additive path is corrected. The shell answers for what it can do. A route the bundle does not list, or lists needing a grant this app does not implement, settles as `native-route` and downloads nothing; so does a desktop that ships no bundle at all, which is the one blocked verdict that is not a wall, because a desktop with no bundle declares no page route and there is no workspace to refuse. The route is answered before the compat verdict for the same reason: a bundle this shell cannot open is not a reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx` mounts the shell when the flag is on and takes the native list back as the fallback, and both routes read the flag through one hook so the census stays the whole census. A tap on a worktree row still opens the native session screen. The page posts `notify { name: 'navigate', href }` behind the `navigate` grant, which is not a convention: `notify` is a closed union, so an older shell refuses the whole frame and the page checks the grant before it posts. The shell pushes the target over the still-mounted view, so Back reveals the page with nothing reloaded. `route-handoff.ts` and its web sibling are the seam, router-shaped so the list's own hook and the recorder's adapter are untouched and no golden moves: the web file wraps the three members that leave the document and hands back any target outside the page routes `init` named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-route and navigate refs after the commit Same class again: the last two writes this branch adds join the commit-phase effect, so nothing this hook holds is written while React is rendering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3) `handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left the page and never whether the shell accepted it. Two hrefs the app builds today were posted, answered true and suppressed the local fallback, so the tap did nothing at all: the Connection-log link's object form, which `String` turns into `[object Object]`, and any href carrying a fragment, because the pathname is stripped to match and the whole href is what goes on the wire. Object hrefs now resolve the way the router resolves them, and the string is checked against the envelope's own pattern and cap before it is posted; anything that fails falls through to the local router, which is the policy this module already states. Whether a target names a screen that exists is shape's business no longer, and the comment says C1.7 owns it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name routes in the manifest field list the builder emits C1.3 added `routes` to every manifest this builder writes, and the Phase A contract test still listed eight keys, which is what went red in CI. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold a navigate target to the same segment rule as the shell's The href pattern is built from the segment source C1.2 tightened, and nothing said so: a spelling one pattern refused while the other took it would be a hole with a `notify` already pointed at it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe the navigations this branch added C1.1's new case builds its own probe, and on this branch a probe also collects the hrefs the page hands back. The file stopped typechecking on the merge, which the tests ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
084e101328 |
fix(session-search): keep the title filter while full-text search is off (#21550)
* fix(session-search): keep the title filter while full-text search is off The search box switched to index search the moment the query was non-blank, so on a computer with indexing off the panel showed only the consent card and hid every session. Derive the mode once in useAiVaultPanelSearch: while local consent is pending the box stays the legacy title filter and the consent card becomes an offer above the filtered results. * test(session-search): cover the legacy title filter while indexing is off The panel test fails on the pre-fix code with no session rows rendered. * refactor(session-search): drop Not now and name the query flag queried The dismiss button's only remaining job was wiping the user's live title filter. Keep localConsent and expose queried so the panel reads one flag per fact, and assert the post-enable and empty-box states. * refactor(session-search): guard first and name the search-mode flags for what they mean |
||
|
|
b8f3b1ec00 |
feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) (#21501)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
7080eb0604 |
fix(relay): bound the idle-rehome candidate poll to a window of decisions (#21557)
* fix(relay): bound the idle-rehome candidate poll to a window of decisions The director's idle-regional-rehome poll built every (eligible host x target cell in its preferred region) pair, applied the cohort predicate downstream of that fan-out, sorted the lot, and took LIMIT 100 OFFSET n. Its cost was set by the size of the fleet and the width of the cohort, so raising the cohort from 10% to 100% pushed it past the serving pool's 5 s statement_timeout and the rollout stalled at 0.37 hosts/min. The poll now resolves the cell inventory once (tens of rows), takes a bounded window of decision rows in primary-key order from a keyset cursor with the cohort, freshness and cross-region predicates applied first, verifies only that window against the host-side gates, and ranks targets in the process. Same candidates in the same priority order; the work per poll no longer depends on the cohort or the fleet. Adds a once-a-minute aggregated poll summary so an operator can tell a poll gated by the dispatch budget from one that found nobody to move. Co-Authored-By: Claude <noreply@anthropic.com> * fix(relay): pin the rehome verification to the window's exact keys The window read and the verification read take separate snapshots. The verification repeated the window's predicate with its own LIMIT, so a decision that turned eligible between the two reads shifted that LIMIT and pushed the window's last host out of it -- while the cursor still advanced past that host, skipping it for a whole sweep. The verification now names the keys the window returned. Its LIMIT stays as the optimisation fence that stops Postgres flattening the subquery, but can no longer truncate a key set that is at most one window long. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
742fa638f3 | Update README downloads badge | ||
|
|
d043cfbbee |
feat(mobile): the page mounts on the shell's init, with the client injected and a fault boundary (OTA phase C, C1.1) (#21500)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
cd81725d70 |
feat(terminal): configure URL click and middle-click behavior (#21438)
* feat(terminal): configure URL click behavior * fix(i18n): include terminal link setting title * fix(i18n): localize terminal click controls * fix(settings): update terminal URL click title |
||
|
|
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 |
||
|
|
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 |
||
|
|
c5733e812a | fix(opencode-usage): count cache-read tokens (#21522) | ||
|
|
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 |