mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
505dbf67e48dfa8ac111cd40bf2fb4da7cef8dbf
35
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3f9582bc90 |
fix(mobile): removing a host deletes its page cache through the one process store (#22352)
* fix(mobile): removing a host deletes its page cache through the one process store Host removal never deleted the removed host's page generation cache, so it sat on disk until four other hosts were activated and a re-pair could reopen the old tree. Every caller also minted its own GenerationStore with its own queue, so a removal's index or update-failure-log write could drop the mounted session's write landing inside it. The store is now one per process (processGenerationStore, with a reset-for-tests seam); the shell runtime, host removal and Troubleshoot all share it, and removal deletes the host's cache after the metadata commits, fire-and-forget beside the failure forget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the cold-start store per mount is the test's, not production's Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
7240368726 |
feat(mobile): a failed hybrid-shell update is recorded on the device and shown in Troubleshoot (#22321)
* feat(mobile): name why a bundle fetch refused what arrived
The fetch threw plain errors whose only content was prose naming asset
paths and hashes, so a caller could not keep the cause without keeping
the prose. Each refusal now carries a code beside the unchanged message.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): record why a hybrid shell update failed, on the device
A release build forwards no console output to logcat, so a refused or
failed page update left the fallback banner and nothing else. Every exit
from a failed update read now emits a record-update-failure effect: the
cause as a closed code (never an error message), the generation offered
and the one on disk, and what went on screen instead. The runner stamps
host id and time and the generation store appends it to a bounded log in
the cache root, five per host and twenty in all, oldest evicted first.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): forget a removed host's recorded update failures
Removal clears the host's entries from the shell's update-failure log
after the metadata commit, unawaited and best-effort: it is evidence
about a host that is gone and never a reason to hold the removal.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): show recorded update failures in Troubleshoot
A "Workspace updates" section lists the newest recorded failure of each
paired host: the reason, the generation offered, and what the shell
showed instead. It renders nothing until a failure has been recorded and
mounts only where the hybrid shell can run.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the update-failure row doubles without casts
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): forget a host's update failures once a newer generation commits
The Troubleshoot row reads "Last update from Host N failed", which stops
being true the moment a later update from that host lands. The activated
step for the build this flow downloaded now emits forget-update-failures
for the host. A cache open, an offline open and a same-build hit activate
a build the flow never requested, so they leave the record alone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-pin the session closure for the shared journal producer #22299 added
main at
|
||
|
|
0ab2ba3480 |
fix(mobile): the page stops writing, importing and requesting what it cannot use (#22241)
* fix(mobile): the page keeps no host app-version record `host-status-gates.ts` runs above every page route, and a readable `status.get` had it write `orca:host-app-version:v1:<hostId>` through `host-app-version-store.ts`. Inside the page AsyncStorage is the bridge's adapter and that key is not one `page-storage-keys.ts` hands a route, so every mount posted a write the shell refused and logged as `storage-write-dropped`. Not admitted through the storage seam, because the page never reads it back: the record's only reader is the native troubleshoot screen's `native-diagnostics-operations.ts`, which is not in the page's bundle. A `.web` sibling keeps no record instead. The bounds check moves to `host-app-version.ts` so both hosts read a reported version the same way. The session render check now collects warnings as well as errors and answers `status.get`, which is what arms the write: the other cases' double answers no RPC, so the drop needed a reply rather than a control. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page does not import expo-notifications `DevicePushTokenAutoRegistration.fx` runs at import: it adds a push-token listener React Native Web answers with a warning, and it reads the persisted server registration out of `window.localStorage` behind a `typeof localStorage === 'undefined'` guard. The Android shell's WebView has DOM storage off, where `window.localStorage` is `null` rather than undefined, so the guard passed and the read raised "Cannot read properties of null (reading 'getItem')" at error level on every page load. Two modules imported the package — `push-token.ts` and `desktop-notification-channel.ts`, both reached through `push-registration.ts`, which the host layout pulls in via the host screen's remove action. Both get a `.web` sibling. The page holds no device push token and creates no Android channel; push registration needs a token the shell owns and a gateway the page has no client for. Every call in those two files was already inert on web, so a page that imports one behaves correctly and still loads the package: the closure check beside them is what keeps a third importer out. The session render check adds the device's own shape — `localStorage` reading `null` — and reds on the error the emulator saw. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page declares an icon, so no browser asks for one With none declared a browser asks the origin for /favicon.ico on its own, and the shell's asset server answers 403 because the path is in no manifest — which the emulator run saw repeatedly. The document now carries `<link rel="icon" href="data:," />`, a browser's own way of being told there is no icon. An empty data URI rather than an asset: the page is a WebView document with no tab to put an icon in, and the bundle's images are content-hashed route assets whose names change with their bytes. `img-src 'self' data: https:` already admits the scheme. Two assertions, because each is blind where the other sees. The build check reads the document and runs everywhere. The session render check reads the request, which only a full Chrome makes — `ORCA_MOBILE_WEB_RENDER_BROWSER`, what CI resolves — and reads it off the server's own log: a favicon fetch comes from the browser process rather than the page, so Playwright's request events never report one. It also settles on network idle first, because the fetch comes after the text the route waited on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): trim the page-noise comments to the bar Comment-only. The three `.web` siblings, the document's icon line and the three override reasons each said their cause once and then said it again; each now states what the page keeps and why, once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-pin the session closure after expo-notifications left Measured on this head with all five generators run first, against a scratch worktree detached at the base, which reads the committed pin exactly: 4271 modules and 1023 local. modules 4271 -> 4210 (-61) local modules 1023 -> 1024 (+1) 65 modules leave and 4 join. 62 of the 65 are vendored: expo-notifications' own 55, and expo-application, badgin, abort-controller and event-target-shim behind them. The other three are the native files the `.web` siblings replace, so the siblings cost the local count nothing and its +1 is `host-app-version.ts`, the one new module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): count the packages the closure note names The note said 62 vendored modules left and then named five packages without counts, so the names read as the whole of the 62 and summed to five. Each carries its own count now: 55 + 3 + 2 + 1 + 1. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
40b2230508 |
test(mobile): typecheck the test files on a ratchet, and pin the reply enums where tsc looks (#21298)
* fix(mobile): move the last six reply-enum pins where tsc looks mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>` coverage record in a schema test is never typechecked: the two that existed (SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four closed enums beside them had only a doc citation of the host type. Each arm list moves into its schema module as hostUnionArms<Union>(), which #21269 introduced for the same reason, and each test iterates the exported list instead of holding its own copy: - SSH_CONNECTION_STATUS to SshConnectionStatus - PROJECT_OWNER_TYPE to GitHubProjectOwnerType - DETAIL_FILE_STATUS to GitHubPRFile['status'] - PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal arms of MobilePushTestResult and MobilePushRegisterResult - SETUP_RUN_POLICIES to SetupRunPolicy openEnum's parameter widens from a non-empty tuple to `readonly string[]` so a hostUnionArms list can feed it. z.enum already accepts the same, so the tuple constraint only excluded callers zod itself takes; behaviour unchanged. Twelve mutations prove the pins: dropping one arm and adding a bogus one each fail mobile tsc in all six places. Zero goldens move, the schemas' behaviour being unchanged, and the 21 recording suites pass at the existing baseline. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fix the type errors in eighteen test files Found by typechecking the tests for the first time (see the config that follows). All mechanical, none weakens a product type: - 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils where act wants void, so each becomes a block. The async ones await only a genuinely promise-returning call, so no extra microtask tick is introduced. - Four fixtures were stale against a product type that gained a required member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError, the branch-compare summary's errorMessage, and SessionOptionDescriptor's transport, which #20884 added precisely so a producer could not inherit the wrong lane's rendering by omission. - `getLastConnectedAt` on the shared relay fake was typed `() => null`, which refused the timestamp two escalation suites assign to it. - Two holders used before assignment take `!`, one `advance!.kind === ...` becomes `advance?.kind`, one widened status arm takes `as const`, and the Expo notification fixture keeps `data` required because the dismissal cases assign through it. 631 test files pass, 6222 tests, unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): typecheck the test files, on a ratchet mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the release bundle, and vitest transpiles without checking types. Nothing had ever typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written in one proved nothing and why 144 of the 630 test files had drifted. tsconfig.test.json is that program with the tests put back, behind `typecheck:tests`. Four files stay out: they import the desktop main process or src/shared/child-process, which are written against @types/node, and this program's libs are React Native's, where setTimeout answers a number rather than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the desktop rather than about mobile; vitest runs those four under Node, which is where they belong. The CI gate is a ratchet rather than the raw typecheck, modelled on check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that set and fails when a file that checks today stops checking, or when a baseline entry starts checking and was not pruned. The list may only shrink. Why not zero: 180 of the remaining 510 errors are one seam — tests locate mocked react-native components by string name, which `ElementType` does not admit — and closing it means either 180 casts or a global JSX declaration for the mocked names. That is a design decision, not a mechanical fix, so it is left for a follow-up rather than made here. The rest are smaller clusters of the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing, and createElement props fixtures. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile-recorder): correct the corpus counts and the salvage claim The oracle section still quoted the corpus as 368 scenarios and 727 goldens; it is 393 and 778, and the three replay suites report 781 tests. Each number now names the command that measures it. "No golden carries one" was the load-bearing error: 44 goldens carry a recorded `reply-salvage` today, starting with the push-test unknown-reason scenario #21176 added for exactly that purpose. The paragraph claimed the observation pins an absence when on those families it pins a recorded drop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the tests-typecheck ratchet's parser The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail under an error. Counting those as filenames would write unparseable entries into the baseline and leave the gate unprunable, so the parser is pinned on that shape as well as on the added/stale diff. Written against the gate itself: it flagged this file before the directive it carried was removed, which is the end-to-end proof the spawn half works. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): await the timer advances the act() rewrite dropped Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a braced body left the returned promise floating at 27 sites, so the advance was no longer ordered before the assertions that follow it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): unshadow MobileHostCard's .tsx suite A wildcard `include` keeps only the higher-priority extension, so MobileHostCard.test.tsx sat outside every tsc program while MobileHostCard.test.ts existed beside it. Its one error is the same react-test-renderer seam its sibling is baselined for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census every test file into the typecheck program The ratchet diffs only files that error, so a test excluded from tsconfig.test.json or shadowed by a sibling extension left the gate silently. Every *.test.ts(x) on disk must now be in the program or named in TESTS_OUTSIDE_PROGRAM with its reason. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(shared): make the enum helpers refuse the ways they can prove nothing openEnum takes a `const` T so a bare literal keeps its arms rather than widening to string. hostUnionArms blocks inference of U with NoInfer and defaults it to never, so a call that omits the host union — where the record would only pin itself — no longer compiles. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): describe the census and correct the baseline count Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the push fixture cast its SAFETY rationale Widening the pre-existing cast made the changed-code gate attribute it as a new finding. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build the push fixtures as typed notifications Replaces the `as unknown as` cast with Expo's own types, filling FirebaseRemoteMessage and its notification once in two builders, and passes the data payload in rather than mutating through an optional member. Typing the fixture showed one assertion comparing the scheduled content against the whole arriving content, which only held while the cast let the fixture omit the two members the presenter drops; it now names the four members the presenter forwards. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the grouped-question advance read non-optional `advance?.kind` let an absent advance take the null-draft branch instead of failing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run the tests-typecheck ratchet on Windows Spawns tsc's JS entry on this Node instead of the node_modules/.bin shim, which is a POSIX shell script that Windows resolves to tsc.CMD and then appends .exe to. Parsed paths are normalised to POSIX so a Windows run does not read every baseline entry as both stale and added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close the ratchet's @ts-nocheck hole and read tsc once tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed" with one line, pruned, and never checked again; the census now names any program test file whose leading comment carries the directive. `--noEmit --listFiles` answers both questions in one pass, so the gate spawns tsc once rather than twice. Corrects the two stale counts, and states hostUnionArms' real reason for living in the schema module now that tests are typechecked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
eabfbaab88 |
refactor(mobile): drop the unreachable dispose-before-ready notifications arm (#21293)
* test(mobile): pin the desktop-notification dispose-before-ready contract Drives `subscribeToDesktopNotifications` through the real `RpcClientStreamRegistry` so the disposer's effect on a later `ready` reply is stated rather than implied. Both cases pass against the current module, before any code is removed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the unreachable dispose-before-ready notifications arm `disposed` is set only on the first line of the disposer, whose next statement detaches the stream listener in every transport, so the `ready` arm can never observe it. Removing the branch changes no behaviour and moves no golden. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin cancel fencing in the relay and logical stream layers The notifications comment claims every transport detaches a listener inside its disposer, but only the stream registry was pinned. Adds the same live/cancelled differential pair to the relay stream manager and the logical client, the latter against a physical session with an inert disposer so only the logical guard can fence the late event. Drops a self-comparing assertion to a length check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the notifications registry fake instead of asserting it The changed-code quality gate rejected three `as` casts. The fake client is now declared `RpcClient`, so the compiler checks it really satisfies the port, and the registry's `unknown` send port is narrowed by a reader that throws on a frame without a string id and method rather than asserting one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3e32b83522 |
refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7) (#21176)
* refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7)
Twenty-one unchecked reply readers across thirteen files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`, or
a card left "proven" over a reply that carried no rows. Deliberately a behaviour
change on malformed replies only.
What each domain required, and why it required no more:
- notifications (5 readers). All four call sites read the payload through `?.`,
so every schema is nullish at the top level and no member is required. The
test-push `reason` and the register `reason` become closed enums, because the
two comparisons against them are the whole of what they decide and an arm this
build does not know took the generic copy on main too. The stream unsubscribe
and the unregister read no body at all.
- components (4). `repo.hooks` requires `source` and nothing else: the drawer
assigns it straight into `SetupHookDetails.source`, whose type is
`string | null`, with no guard in between — nullable so the "no hooks file"
answer keeps its explicit null. `setupTrust` is nullable as well as optional
because the `components-setup-ask` fixture sends an explicit null, and
salvaging that would move a `normal` golden. `ui.get`'s trust record salvages
per repo, so one unreadable repo cannot cost the others their approvals. The
Codex redeem reply stays `z.unknown()`: `decodeResetResult` is a real
scope-and-snapshot validator and splitting it would give one reply two refusal
rules.
- terminal (4). The send verdict and the viewport pair keep main's exact
`=== true` projections. `terminalSendAcceptedSchema` moves here from the
session domain, which now re-exports it: terminal is the lower layer and two
identical copies could drift on what "delivered" means.
`terminal-send-rpc-response.ts` is deleted, its projection now being the
schema's.
- transport (3). `status.get` declares its five members and requires the object;
the three callers disagree about what an unreadable status means, so each keeps
its own verdict behind a named reader — the gate wants the failure, and the
probe and the pairing race must not have it, because both call `interpret`
inside a `.then` fulfilment handler where a throw becomes a detached rejection.
`capabilities` salvages whole rather than per element, which is main's own rule
and what `transport-capability-probe-non-string-capabilities-drop` records.
The two pairing readers are the shared credential contract itself, moved off
the four call sites that each ran `.parse()` on the interpreted value; its
`.strict()` is main's shipped rule for that released surface, not a new one.
- home (2), worktree (2), browser (1). The stats row is checked as an object and
nothing more, `totalHomeStats` being the reader that says so itself; its
per-host slot is now typed as the wire row it holds rather than as the computed
total. `worktree.ps` cannot require `worktrees`: the host answers a union whose
unchanged arm carries `{ unchanged, snapshotId }` and no rows. The twelve
browser commands read no body; `browser.goto`'s settled URL stays nullish
because `navigateToAddress` is inline in `MobileBrowserPane.tsx`, which no
adapter mounts, and a move there would ship unevidenced.
Three fixtures were wrong and are corrected, each disclosed rather than worked
around: the runtime-context test kept a content hash directly under a repo key,
which is not a shape `ui.get` sends; and two snapshot-client tests ran their
reply list dry and handed `fetch` an absent result while claiming to model a
transport failure.
`push-test-envelope` is re-anchored at the same defect's new home, the cast
having been deleted. The boundary test's offender floor comes down from 20 to 10
with the list, which is what its own comment says it is for.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the corpus and re-record step 7's checked reply readers
`baseline` moves to this branch's product commit, which is what `--record`
compares the fenced tree against, and every one of the 758 goldens is
re-recorded from it. The repin is what rewrites the `baseline` header on all of
them; nothing else about the corpus moves except the bodies disclosed below.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mutate the workspace catalog's reader back to unchecked
The step-7 defect evidence needs a scenario whose reply is the one the change
moves. Every pilot scenario in the catalog family scripts a well-formed reply, so
a mutant that only changes how a *malformed* reply reads has nowhere to diverge —
which is why the pilot's own suite passed against an unchecked catalog reader
while its matrix golden failed.
`worktree-catalog-snapshot-unreadable` scripts `worktree.ps` answering
`{ ok: true }` with no result at all, which is what `result-absent` drives at the
matrix site, and records the fetch rejecting with `RpcIncompatibleReplyError`.
`worktree-catalog-unchecked-reader` then swaps the operation's reader for one that
answers `compatible: true` for every payload — main's reader, in one line — and
the recording moves back to a fulfilled fetch carrying
`admission: { kind: 'invalid' }`, which is the answer that let a broken catalog
render as an empty host (STA-3123).
One golden added and none moved: the manifest sits outside the fenced paths, the
family's matrix base is still `worktree-catalog-snapshot`, and the mutation
registry is not part of `recorderSha256`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the push-test reason arms the closed enum constrains
`pushDeliveryTestResultSchema.reason` closes over the four arms of the host's
`MobilePushTestResult` (src/shared/mobile-push-contract.ts:99), but no scenario
carried the member, so the corpus could not have caught a wrong vocabulary.
Three scenarios on the existing display-test mount carry it now: the two arms
the screen branches on and one arm no build knows.
Each golden was recorded first at the main pin
|
||
|
|
f78483ec29 |
refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6, migration 1) (#20954)
* test(mobile): record the three step-6 families at the pin, and record a stream listener that dies Step 6 migrates the requests step 4 left behind because they share an effect with a `client.subscribe`. This records them first, from the pinned baseline, so the refactor that follows has a parity oracle. Three new families, one adapter module each: - `session.native-chat-page` — the older-history page. The read is a callback, but only the mount effect's `nativeChat.subscribe` arms what it pages against, so the frames are the setup: the snapshot's `beforeOffset` decides whether the request carries a cursor or asks for a growing tail. A cutover and a second snapshot pin the reconnect replay merging into paged-in history instead of collapsing the window. - `notifications.desktop-stream` — the desktop notification socket: the subscribe, the catch-up read its `ready` arms, the tray dismissals its events drive, and the server unsubscribe the disposer sends. Split in two so the base scenario's matrix sites all have partition-stable params: a variant that answers the second `ready` differently leaves the unsubscribe carrying the first subscription id, which the base's scripted params could not assert. - `session.terminal-gesture-input` — the debounced gesture flush and the menu's clear-buffer. Neither rides a subscription; a mount holding no terminal ref reaches both. The engine change is what makes the first two recordable at all. `ScriptedRpcTransport.frame` now returns what the product listener threw instead of throwing it on, and the runner records it as a `stream-listener-crash` effect. Only the two `runtime.clientEvents` listeners check that a frame payload is an object before reading its `type`; every other subscribing family took the matrix's `result-absent` and `result-null` partitions as an uncaught TypeError, which failed the suite rather than recording what a malformed frame does to a subscription. That is the same rule the crash boundary already holds for a screen and the unhandled-rejection window for a detached effect. The scenario's own faults stay loud: a missing subscribe payload, a params mismatch and a closed stream are all raised outside the caught region. `recorderSha256` therefore moves, so all 679 pre-existing goldens are re-recorded from the pin with this branch's recorder laid over it. Every one of them moves exactly one line and that line is `recorderSha256`: no `adapterSha256`, no `scenarioSha256` and no observation moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6) Five references over four files leave the raw request port. Each was held out of step 4 because a request-only recorder could not mount it; the recordings landed in the previous commit and no golden moves here. - `use-live-worktree-name.ts` — `worktree.show` inside the focus effect that opens `runtime.clientEvents`. It reuses `sessionWorktreeRecordRead`, which is the diff-comment loader's reader renamed: both consumers read the `worktree` member whole and narrow their own field off it, so a second family would have been a second name for the same wire. The resolution still comes off the raw reply, because `selector_not_found` is what proves the worktree is gone and no acceptance policy carries a refusal code; the skip that follows is the same verdict main's `!response.ok` reached, since a refusal is the only reply this policy declines. - `use-mobile-native-chat-session.ts` — `nativeChat.readSession` in the paging callback. The payload stays whole because the reply is a union: an older runtime answers `{ error }` in place of a window, and the caller discriminates before reading a message list. - `mobile-notifications.ts` — `notifications.unsubscribe` in the `ready` branch of the subscription callback, in its own module rather than beside the push-route sends: one is the route this device holds with a gateway, the other the socket the paired connection holds. - `use-mobile-session-terminal-input.ts` — the gesture flush reuses `terminalInputSend`, which already carried the four other terminal-input call sites and the same accepted-verdict, and the menu's clear gets `terminalBufferClear` beside it. The clear is a skip because main never read the envelope: it toasted success on any fulfilled reply, so only a transport rejection reached the failure toast. That is preserved, not repaired. `mobile-session-route-parity.test.ts` refreshes three pins with their reason: the callback bodies and the twelve nested-function bodies moved where those send expressions were rewritten, and the runtime-string count drops by two because `terminal.send` and `terminal.clearBuffer` are now fixed at their operation's definition instead of spelled at the call site. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold the subscription coverage as a checked inventory instead of a README paragraph Every product `client.subscribe` is now an entry in `mobile/src/transport/rpc-subscription-inventory.ts`, classified as recorded (naming its family), an unwritten scenario, or walled with the wall named. `rpc-subscription-boundary.test.ts` fails on a new site with no entry, an entry whose file no longer subscribes, an entry naming a method the file does not open, and a `recorded` entry whose family the scenario manifest does not have. Both the unlisted-site and unresolved-family gates were checked by removing an entry and by misspelling a family; each fails on its own assertion. The paragraph this replaces said nine sites when there were ten. It counted over `mobile/src`, and the host screen's `accounts.subscribe` lives under `app/` — so the scan here covers both roots, the way the raw-port ratchet next door does. Ten sites today: four recorded, two unwritten scenarios, four walled (two on the webview ref, one on the multi-host client context, one on two unsubstituted view members). Unlike the raw-port inventory this list does not count down to zero. A typed operation fixes one method, one acceptance and one reader for one reply; a stream has many, and replacing a subscribe is not what this is asking for. The question it holds is the other one — which stream a golden actually has, and for the rest, what exactly stops it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the notification stream close, which writes nothing to the wire Deleting `unsubscribeStream()` from the notification cleanup — the local close, not the `notifications.unsubscribe` RPC beside it — survived all 810 tests. Neither unsubscribe builder in the stream registry knows `notifications.subscribe`, so closing that stream sends no frame; the mutant leaks a live subscription record instead, and the leak only surfaces when the logical client replays it onto the next session. `notifications-desktop-stream-closed` stops the stream and then cuts over, where the leak becomes a second `notifications.subscribe` payload. Recorded at the pin. No existing golden moves: the new scenario is appended, so it is not the family's matrix base, and every notification matrix site already had a fulfilled reply to replay. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the accounts screen's real wall, which is ScrollView and Alert The entry blamed `expo-router.useFocusEffect`, which is substituted, and the inventory's own `use-live-worktree-name` is recorded while importing it. Probed by mounting the screen through the trap: the first refusal is `Unsubstituted native member: react-native.ScrollView`, and `Alert` refuses too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the terminal-send response reader that lost its last caller `isTerminalSendRpcAccepted` read the verdict off a whole envelope, which is what the raw call site did. Both callers now send through an operation and read the admitted payload, so the response form had only its own test left. The three cases move onto `isTerminalSendResultAccepted`, with the refusal envelope's missing result standing in for the failed response. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): attribute a frame crash to the listener that threw, not to the registry The try wrapped `stream.deliver`, so anything the registry raised on its way to the listener was recorded as a `stream-listener-crash` effect and blamed on the product. A reply like `{ok:false}` with no error object throws reaching for `error.message` before any listener runs, and that is a scenario that stopped matching, not an observation. Only the product's own `onData` is wrapped now. The throw is stashed and rethrown unchanged, so the registry still sees it the way a device's message handler does and what it skips after a dead listener stays recorded rather than invented; `frame` reports it only when the error it caught is the one the listener raised. `FrameListenerCrash` is local to the file again. Engine change, so every golden re-records: 694 files, every changed line the `recorderSha256` header, no body movement. Against main the set is 679 modified header-only and the same 15 added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read the frame listener stash through a method, not a narrowed field `this.listenerCrash = null` before the try narrows the property to `null` for the rest of `frame`, so the catch compared against `never` and mobile's own `tsc --noEmit` failed. A private taker returns the declared type. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): abort a registry throw that stashed nothing, and fold the last native-chat read module in The frame catch compared `crashed?.error !== error`, which is false when nothing was stashed and the registry threw `undefined`, so that abort was swallowed and `frame` reported a clean delivery. It now asks whether a listener crashed at all. Also: `nativeChatSessionPageRead` moves beside the three other `nativeChat.*` reads and its one-export module goes; the session read header names the whole `worktree.show` record rather than review notes; the guarded-listener count is three, not two; the README names the ten subscribing sites blur is unrecorded across; and the gesture flush reads the send verdict as `=== true` like the other four sites. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): drop an oxlint disable the rule never needed `no-throw-literal` is not enabled here, so the directive read as unused and failed the changed-code quality gate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
b8d4cde09f |
refactor(mobile): send six screen-mounted call sites through typed RpcOperations (step 4, wave 3) (#20919)
* test(mobile): record six screen-mounted call sites before migrating them Five new mount adapters and six scenarios, recorded against the pinned baseline's product code so the goldens are main's behaviour, not the refactor's. Each site is a screen the recorder could not previously mount: - `home.host-accounts` mounts `fetchMobileHomeAccounts`, whose decoder is re-exported through `AccountUsage.tsx`. That module loads under the mount loader, so the inventory's "no recording can load it" was already stale. - `notifications.display-test-screen` mounts the settings push probe and presses its button by reading the handler back off the rendered inert `Pressable`. - `aiVault.history-screen` mounts the history panel, which is where the last `worktree.ps` lives. Split in two: the base stops once the worktree list has seeded the scopes, because a reply partition there changes the scopePaths the downstream `aiVault.listSessions` carries, and a matrix variant cannot assert params it moved. The full chain is a second scenario, driven as a pilot only. - `tasks.route-repo-list` mounts the tasks screen-root hook and calls its own `ensureLoaded`, which is the only thing that fires `repo.list`. - `linear.select-workspace-picker` calls the render helper the tasks surface calls and invokes the `onSelect` on the element it returns. The picker draws inside `BottomDrawer`, whose reanimated timing driver and gesture builder the recorder would have to impersonate for a row to exist; the closure is the same either way, and the workspace a selection carries comes from the scenario. Five substitute members are added, each with the recording that reads it: `react-native-safe-area-context.useSafeAreaInsets` and `expo-router.useLocalSearchParams` for `tasks.route-repo-list`, and `react-native.TextInput`, `.SectionList` and `.RefreshControl` for `aiVault.history-screen` once its list renders. `useLocalSearchParams` answers one pinned route for the same reason the window size is pinned: a screen's own address is not a device reading, and the one screen that reads it sends `repo.list`, which takes no params. Touching the substitute table moves `recorderSha256`, so all 641 existing goldens are re-recorded. Recorded from a detached worktree at the pinned baseline with this branch's recorder laid over it: every pre-existing golden is header-only, verified by resolving both sides through the value pool — 641 header-only, 0 body, 0 deleted, one distinct `recorderSha256`, `baseline` and `lockfileSha256` across all of them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the linear workspace picker's model fixture `mobile/tsconfig.json` covers the recorder, and the fixture's setters were written with the argument the product happens to pass rather than the `SetStateAction` the model declares. Typing them moves `adapterSha256` on the two goldens recorded through this module, so they are re-recorded here rather than in the refactor commit, which must move none. Re-recorded at the pinned baseline: `linear-select-workspace` and its reply matrix, header-only, bodies unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send six screen-mounted call sites through typed RpcOperations Nine references off the raw request port, across six files. Every one is proven against the goldens recorded in the previous commit from the pinned baseline's product code: this commit moves no file under mobile/rpc-foundation/goldens. Reused rather than redefined: - `worktree.ps` in the history panel sends through `worktreeCatalogRead`. Same question, same acceptance — a refused list leaves the screen on what it holds. - `repo.list` in the tasks screen-root hook sends through `newTabRepoListRead`. Its policy raises the host's message and its reader takes `repos` off the payload while preserving the property-read exception a null result used to throw at the cast, which is what this call site did by hand. Its name still says new-tab; a third consumer does not make renaming it this bucket's business. Four operations are new, each because no existing reader on the method takes this consumer's input: - `files.read-directory-or-skip` and `files.legacy-explorer-list-or-skip` for the explorer. Both skip, because neither refusal is the operation's to decide: the readDir refusal code selects the legacy fallback and the list refusal supplies the message. The existing `files.list-or-skip` reads the `files` member alone, and the explorer also needs `truncated` for the "Showing first 5000" note. - `accounts.home-snapshot-or-skip` for the Home card, decoded by `decodeAccountsSnapshot` at the call site as before. - `notifications.test-push-or-skip` for the settings probe, whose `forbidden` and `method_not_found` refusals mean "try the next desktop". - `linear.select-workspace-or-skip` for the filter sheet. Two behaviours are preserved rather than repaired, both recorded: - The workspace switch never read its reply. `.then(() => loadLinearContext())` runs on a refusal exactly as on a success, so only a transport rejection reaches the error copy. Interpreting the operation here would surface a refused switch for the first time; that is a product change with its own re-record. - `app/terminal-settings.tsx` still reads `ms` off the reply envelope instead of off its result, so the value is always undefined. It did not migrate, and the inventory now carries the defect as its own note. Four mutants are added, one per new family that admits a state-only one: the Home snapshot, the push test result and the tasks repo list each decoded one level above the envelope, and the workspace switch with its context reload dropped. `aiVault.history-screen` gets none and says why in the suite: everything `worktree.ps` publishes also moves the `scopePaths` the next scripted completion asserts, so a mutant aborts the sequence instead of diverging from it. Its evidence is the reply matrix at that request. The tasks source-parity ratchet moves with the family it guards: hook, statement, declaration, render and style counts are unchanged, and the semantic source is a pure deletion of four lines — two `rpc:` call signatures and the two method literals they carried. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): matrix the six new screen families' replies One golden per scripted reply, eleven partitions each, recorded at the pinned baseline alongside the pilots. Seven sites: `accounts.list`, `notifications.testPush`, `repo.list`, `linear.selectWorkspace`, and all three of the history screen's — `worktree.ps` and the two `status.get` reads its scan chains off the worktree list. The history matrix is also that family's defect evidence in place of a mutant: every partition at `worktree.ps` changes the `scopePaths` the downstream `aiVault.listSessions` carries, and the sender args are recorded with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct three operation and mutant comments Comment-only, no product behaviour and no golden movement. - `worktreeCatalogRead` says two readers; there are three. Names the third (the agent-history panel's `scopePaths` seed) and drops the stale count from the module header, which described call sites rather than the two operations. - `newTabRepoListRead`'s census counted the two operations over `repo.list`, not its own two callers, and claimed both read a workspace's connection id. The tasks route keeps the whole list for its repo pickers. The split from `nativeChatRepoListRead` stays where it belongs: acceptance. - The `aiVault.history-screen` mutant note pointed at the reply matrix as the accepted-vs-refused oracle. Decoding `matrix-aivault.history-screen-worktree.ps-1.json` through the value pool shows `normal`'s projected state is identical to all seven non-crashing partitions (spinner, two labels, zero rows). The real oracles are the next request's `scopePaths` (`["/repo/feature"]` vs `[]`) and the crash channel the three `inner-*` partitions land in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): give the second files.list reader its real reason Comment-only, no product behaviour and no golden movement. `legacyFileListRead` claimed "the member reader rejects this consumer's input". Nothing rejects: `rpcUncheckedMemberReader` returns the member, and reusing it here would simply drop `truncated`. The reason the explorer declares its own operation is the other direction. Widening `files.list-or-skip` to a payload reader would split the `workspace-files` variant it shares with `nativeChatFileSearchRead` over `files.searchPaths`, whose only caller feeds both through one `extractPaths` in `use-mobile-native-chat-file-search.ts`, so the member read would move into that hook rather than disappear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): indent the six scenario entries spliced during the merge The conflict on `pilot-scenarios.json` was resolved by id rather than by hunk, splicing this branch's six entries into main's text at the array's close. The splice started at the entry's `{` instead of at its line, so those six lines lost their indentation. oxfmt's only change is those six lines; the parsed document is identical, and the recording suite still matches all 667 goldens, so no scenario digest depends on the raw text. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the merged goldens once at the pin One record for the whole merged tree, at the unchanged baseline |
||
|
|
ea7902cbee |
refactor(mobile): send the device-state holdouts through typed RpcOperations (step 4, wave 3) (#20915)
* test(mobile): record the terminal input surface before migrating it (step 4) Three families the recorder could not reach before, recorded against the pinned baseline's product code so the migration that follows has a parity oracle. The device state these hooks read is real, not declared. The pasteboard is the engine's existing per-recording fixture, so a paste reads the bytes a recorded copy put there one action earlier; the buffered draft store is the product's own useBufferedTerminalDrafts mounted in the same tree. No engine file is touched, so no existing golden moves and no header re-digests: 13 new goldens, 641 unchanged. Only the clipboard's text path is driven. The image path decodes a raster through expo-image-manipulator and stages it on expo-file-system, and recording it would mean inventing image and file-system behaviour. Both paths reach the same send. Two family mutants, one per family whose state() can observe a reply: keeping a refused send's draft cleared, and resolving the first repo's connection instead of the workspace's own. The paste family gets none — the hook returns void and calls onSuccess for an accepted and a refused send alike, so its only reply-dependent behaviour is the takeover report, which lives in the sender list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the device-state holdouts through typed RpcOperations (step 4) Ten references over six files, the last of the raw-port sites whose blocker was that a recording could not reach them. Zero goldens move: every one of the six was recorded first, and the suite replays them against the rewritten code. Two operations are new and four sites reuse one that already fixes their method: - accounts.consumeCodexResetCredit, throw-message, payload unread — the call site's decodeResetResult is one scope-and-snapshot check and splitting it across a reader would put one refusal rule in two places. - notifications.getMissedSince, skip — a background pass with no screen to raise a host message on. The member read stays where the optional chaining was. - repo.list: the accessory's connection lookup joins the new-tab reader, which already threw the host's message; the new-workspace dialog joins the skip reader, which already left the list it had. Same reader, same policies, no new acceptance rule and no third operation on that method. - terminal.send: the composed send, the live keystroke send and the clipboard paste all join terminal.input-send, which the accessory raw send already used and which reads acceptance the same way isTerminalSendRpcAccepted did. The typed contract is stricter than the client's own scope type on the redeem: the catalog pairs each runtime with the distro it may name, while the shared CodexResetCreditExpectedScope does not. The invariant is real and held by the attempt journal's schema, so the narrowing is asserted at the send with that named; the bytes are unchanged. Widening the catalog would be a wire change. Two source-shape ratchets pinned the old call text and move with it. The route parity suite's runtime strings drop from 540 to 537: the three method literals that became operation definitions, and nothing else. Every hook, callback identity, effect, JSX and style pin is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the terminalInputSend and PTY-mode holdout comments `terminalInputSend`'s doc still claimed two call sites. It now has five non-test consumers, all on the same acceptance: the query-reply responder, the live accessory raw send, the session screen's composed draft send and live keystroke send, and the clipboard paste. That comment is where the next person narrowing `object-result-or-null` learns whose lost-ack meaning they are changing, so it names all five and their files. The session inventory block closed with "opens or rides a subscription, or takes its method as a parameter", which no longer covers every holdout below it: `use-mobile-session-terminal-input.ts` is held out for a webview handle. Its own reason also said PTY mode was unavailable in the runner, which this branch's terminal-input adapter contradicts by fixturing the mode map a paste reads. The sentence is narrowed and the holdout restated: PTY mode is recordable, the live webview handle is what is left. Comments only. No product behaviour, no golden, no parity hash moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pair the draft-restore mutant with the refused send `terminal-send-refusal-restores-draft` documents the harm of a refused send that leaves the composed draft cleared, but it was driven by the accepted scenario, where the kill comes from the inverse (a draft restored after a send that landed). The refused scenario shows the documented harm directly: without the restore the input stays empty after the runtime says no. Still one mutant per family, and it kills there — verified by running the suite, `terminal-input-send-refused: kills terminal-send-refusal-restores- draft`. No golden, no product change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the gesture-input holdout and drop a dead repo type Three round-2 corrections, comments and one dead type; no behaviour. The gesture-input holdout claimed a recorder gap that does not exist. The flush path reads refs only — client, connection state, PTY modes, the gesture buckets, active handle and tab type — and the clear-buffer reference optional-chains the webview ref, so a mount with a null terminal ref puts both sends on the wire. The reason now says what is true: those 2 references are migratable as they stand and were out of this PR's bucket. The session summary sentence no longer offers a webview reason. `RuntimeRepoSummary` in mobile-session-route-types.ts lost its last consumer when the accessory hook moved to `MobileRuntimeRepoSummary`; `git grep RuntimeRepoSummary` now finds only the `Mobile`-prefixed type. Deleted. Both refreshed route-parity hashes still credited the `interpretOrThrowRefusalMessage` refresh for their current value. They now state the invariant they pin and this PR's reason for the move: the sends and repo reads inside those bodies name their `RpcOperation` instead of the raw `sendRequest` port. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d130347993 |
refactor(mobile): send the dictation, terminal, notification and browser domains through typed RpcOperations (#20702)
* refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's `recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its merge with main conflicted on that one line in 153 files; every future domain PR would collide with every other in flight the same way. Split the directory at a real seam instead of a filename convention: `adapters/` holds one module per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers the engine only. A new `adapterSha256` covers the source of the module that mounts each operation a golden's scenarios drive, read off the same `mounts` calls that build the table the recording runs against, so the pin cannot name a file the runner did not use. Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file, and an adapter importing a sibling each fail. The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new header field; the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the split recorder/adapter digest Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers `adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and recording ran against the same pinned product tree. git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l 0 The seven `adapterSha256` values partition the 153 goldens by the module each was recorded through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory, 9 tasks, 8 workspace settings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop pinning goldens to recorder inputs no recording can read The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the per-family mutant registry beside it, and the probe-hole witness. None can change a recording -- the loader consults a mutant only when a mutant test asks for one, and no suite but the two recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have and charged every domain a full re-record for it. `mutants/` now holds the table, the registry, the reference states, the mutant suites and the probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts` checks exactly that, and fails if an engine file names the directory or anything outside imports from it. `recorderSha256` also pins only the suites in `recording-drivers.ts`, which `scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or writes one to a scratch directory, is no longer provenance for a recorded file. `OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden. Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to `root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed anything. Each call now spells the root differently. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the mutant and driver exclusions Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153 because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that module now carries its own exposure declaration. git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256)":' | wc -l 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restore the preferences actions the merge resolution dropped #20568 added `resume` and `trust` actions to the `settings.task-preferences` adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the `pilot-mount-adapters.ts` conflict in favour of the registry merge silently discarded them and `tw-task-preferences-resume-write` failed to record at all ("Missing or completed request: ui.set#1"). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens at main's tip after the merge All 208 goldens, header-only. `baseline` moves from |
||
|
|
131d5ab07e |
fix(mobile): reuse current workspace on notification taps (#20310)
* fix(mobile): reuse the current workspace on notification taps * revert(mobile): restore notification setting hint |
||
|
|
341b13cf67 |
Restore mobile push and fix cold-start dismissals (#20068)
* Restore mobile push for delivery validation * fix(mobile): register push task before headless startup * Add authenticated mobile push test and fix iOS release entitlements * Mock push-test transport in notification consent tests * Fix slept workspace test for structured remount result * Fix mobile notification review findings * Pad Android notification icon to prevent square cropping * fix(mobile): present visible Android data pushes in foreground * test: use deterministic clock for teardown deadline * fix(mobile): present foreground pushes through Expo public APIs * fix(mobile): check push eligibility before foreground scheduling * fix(mobile): register push from shared host connection lifecycle |
||
|
|
e187c82678 | Revert mobile push rollout pending delivery investigation (#20040) | ||
|
|
d33354cfd2 |
feat(mobile): receive native push notifications from paired desktops (#19951)
* feat(mobile): deliver native push notifications from paired desktops * fix(mobile): retry push capability probes * fix(mobile): cancel retired push capability probes * fix(mobile): ignore stale push reconciliations * fix(mobile): type capability probe at its boundary * fix(notifications): route mobile push taps to the originating pane * Require explicit mobile push-service consent on upgrade |
||
|
|
d53cbed43f |
revert: hold mobile push feature for user testing (#19203)
Reverts
|
||
|
|
3160b54c69 |
feat: real background push notifications for the mobile app (#8129) (#18554)
* feat(cloud): add the mobile push gateway and its contract package (#8129) A small open-source service that holds the APNs key and FCM credentials and sends background push to paired phones on the desktop's behalf. Hosts authenticate with a box challenge and HMAC proof on their pairing key, the same shape the relay uses, so signed-in and accountless desktops share one path. Tokens are stored; alert text is held only for the coalescing window. The contract doc in docs/reference is the source of truth for every wire shape. The interop test runs the real desktop answerer against a real gateway-issued challenge so transcript drift fails in CI. * feat(push): register phones and send background push from the desktop (#8129) Adds the notifications.remote-push.v1 capability, the registerPush and unregisterPush RPCs on the mobile allowlist, a gateway client with a cached session and 401 re-auth, a durable unregister outbox, and a dispatcher that offers every mobile notification to the gateway after the socket fan-out. The dispatcher is fire-and-forget with one retry and drops registrations the gateway reports dead. Puts agentState on the mobile frame and fixes the #4375 wording so a working agent is never announced as finished. The relay host-proof code moves onto a shared envelope module with no behaviour change. * feat(mobile): background push registration, receive, and settings (#8129) Fetches the native APNs or FCM token, registers it with every paired host that advertises the capability, and re-registers on token change. Foreground pushes are suppressed inside handleNotification against the same seen set the socket path uses, so nothing shows twice. Taps route by host fingerprint. One Background notifications switch, off by default, with the disclaimer and needs-input / finished sub-switches; hidden until a paired desktop is new enough. Adds google-services.json and the expo-notifications plugin. * chore(cloud): Terraform and deploy workflow for the push gateway (#8129) Declares the Cloud Run service, runtime account, secrets, and orca_push database behind push_gateway_enabled, true only in production. The deploy workflow is gated like the relay's, deploys with no traffic, probes /ready and a validate-only FCM send, then shifts traffic. It runs as the shared production deploy account because the Cloud SQL rollout lease grant is foundation-owned; its extra authority is three bindings on the push service. docs/push-gateway.md carries the import commands for the resources created by hand and the APNs key rotation procedure. * docs: describe background notifications on the phone (#8129) * docs: check in the mobile push contract (#8129) Seven committed files cite it as the source of truth for every wire shape; docs/reference is allowlisted per file, so add the entry. * test(push): replay one checked-in host-proof vector on both sides (#8129) Cloud Verify installs only the cloud workspace, so the gateway suite cannot import the desktop answerer. Replace the cross-workspace import with a fixed challenge vector generated from the contract package; the gateway fixture and the desktop answerer each replay it and must produce the same HMAC. A transcript drift on either side now fails in that side's own suite. * fix(cloud): open the push gateway with invoker_iam_disabled, not an allUsers binding (#8129) The production domain-restricted-sharing policy rejects an allUsers run.invoker member, which the runbook anticipated. Opt the service out of invoker IAM the way the relay director already does; the host proof is the authentication either way. * docs(cloud): the push.onorca.dev record exists and is hand-managed (#8129) * fix(push): close review findings in the gateway (#8129) - Quota reservation takes a per-host advisory lock; READ COMMITTED admitted a whole burst past the cap (80/80 without, 60/80 with, against Postgres 16). - Challenge issuance no longer writes push_hosts; the row lands on proof verification. Stale hosts prune after 30 days. Per-IP token bucket on the two unauthenticated routes. - Streaming body limit via hono bodyLimit; a chunked body bypassed the Content-Length check. - registrationIds deduped in the schema; per-host device cap of 64; list bounded to its schema. - Gateway-side challenge TTL is the specified 10 s, not 40 s. - APNs stream settles on close as well as end/error. * fix(push): close review findings in the desktop client (#8129) - A gateway registration the registry cannot persist is enqueued for delete instead of leaking a live token. - Unregister outbox re-reads pending per pass, honours enqueues during a drain, and retries with backoff instead of waiting for the next launch. - Dispatcher batches registrations by 20 rather than starving the rest. - 401 compare-and-clear; a 401 after re-auth is unreachable; refused handshakes and 429s are cached briefly instead of re-handshaking per event. - Service is stopped on quit. * fix(mobile): close review findings in push registration and receive (#8129) - Consent generation guards a register that finishes after the switch went off; the host is re-queued for unregister instead of recorded live. - Foreground pushes seed the watermark before adopting the epoch, so a push on a never-connected session cannot wipe a valid watermark. - aps-environment follows the build via app.config.js; the iOS release workflow sets it to production. A bare plugin entry wrote development. - Pushes the OS showed while closed are marked seen before catch-up replay. - Token null result is not cached; failed capability probes are retried and never block an unregister; coalesced summaries are shown but not marked. - Unresolvable fingerprint routes nowhere and is suppressed in foreground. - Android channel ensured at boot; capability hook diffs clients by identity. * fix(cloud): harden the push deploy workflow and size the gateway to the budget (#8129) - Roll traffic back on a failed post-shift check; delete a candidate that never took traffic; retry the origin probe and the FCM probe. - Assert Terraform-owned scaling instead of mutating it from the workflow. - Build before taking the Cloud SQL rollout lease. - Declare the database pool in Terraform (2 per instance, max 2 instances) and add the gateway to the connection budget; the previous default put the shared instance 65 connections over its ceiling. - State plainly that the shared deploy identity's relay authority is inherited. * fix(push): read the runtime from shared state at push startup (#8129) Threading the runtime through launchDesktopMode put the launch module one line over the 300-line lint budget after the rebase. * fix(push): key the unauthenticated rate limit on the hop Cloud Run wrote (#8129) Cloud Run appends the connecting peer to x-forwarded-for; the limiter read the left-most value, which the caller controls, so a forged first hop earned a fresh bucket per request. * fix(push): close the final security review findings in the gateway and infra (#8129) - app.onError logs only the error name and answers a bare 500; hono's default handler printed the whole error, and a pg error carries the row in detail - a second per-IP bucket (240/min) runs ahead of the bearer lookup on every authenticated route, so forged bearers cannot spend the two-connection pool - one live session per host: minting deletes the host's earlier row - device-less hosts are pruned after 1 h, not 30 d; any keypair mints one free - notificationId is printable ASCII, since it becomes the APNs collapse header - the impersonated FCM probe token is masked in the workflow log - prevent_destroy on the Apple secrets and the orca_push database * fix(push): close the final security review findings in the desktop client (#8129) - fetch never follows a redirect: a 307 would replay the host proof and the phone's token to whatever origin the redirect named - registerPush params are strict and the paired identity is spread last - a per-device bucket (10/min) bounds a phone looping registerPush, which costs a gateway write and a synchronous registry write each time * fix(mobile): close the final security review findings in push receive (#8129) - a push with no epoch can no longer claim a seq-derived dedup key, in the foreground or from the tray; a forged seq:N could otherwise swallow the real bell at that seq - a provider-delivered push with no host catalog, or no fingerprint at all, stays unrouted instead of falling back to the hostId its raw data carries * docs(push): record the ip buckets, session and host retention, and the token-ownership limit (#8129) * fix(push): apply the schema on an untimed pool and retry statement-timeout aborts (#8129) Ports the relay's #18722 pattern to the gateway: DDL runs on a one-connection pool with statement_timeout 0 that is closed before the serving pool opens, and SQLSTATE 57014 joins the bounded transaction retry path. * fix: harden mobile push delivery and deployment recovery * feat: align mobile notification preferences with desktop delivery * fix: accept variable-length APNs device tokens * fix: deduplicate native APNs and background socket notifications |
||
|
|
b521837481 |
fix(mobile): keep hosts visible when credentials are unavailable (#12775)
* fix(mobile): keep hosts visible when credentials are unavailable * fix(mobile): guard unavailable host recovery * fix(mobile): protect replacement credential writes * fix(mobile): retain superseded cleanup intents * fix(mobile): preserve credential cleanup authority * fix(mobile): make host cleanup crash-safe |
||
|
|
885afb55a9 |
fix(mobile): open host editor from root navigation (#12766)
* fix(mobile): open host editor from root navigation * test(mobile): update task navigation router contract |
||
|
|
738f640428 |
fix(mobile): relay UX overhaul — steady status colors, visible relay dials, coordinated deep links (F1-F10)
* fix(mobile): keep healthy relays green through focus and network nudges (F1+F2) Focus/app-resume nudges probe the active relay instead of suspending it; network-change nudges replace it make-before-break, suspending only after a failed dial. Mount, Retry, and host-swap windows read 'connecting' instead of 'disconnected'; the host list keeps last-known worktrees for every not-connected state and spins instead of rendering nothing. * feat(mobile): surface the pairing relay path in the pairing log (F3) The relay candidate was silent during pairing: dialing, E2EE handshake, director recovery, and the winning path now emit redacted phase lines through the same connectOptions.onLog the direct path already used. * docs(mobile): relay UX investigation findings and F0-F10 fix plan * feat(mobile): name and narrate relay dials while they happen (F5) migrateTo forwards the dialing session's connecting/handshaking/reconnecting phases whenever the client is suspended or disconnected — never downgrading a live session — and exposes getPendingPath so the host card can say '· Orca Relay' during the dial instead of only after it. * feat(mobile): race a relay dial when the direct dial stalls (F6) A 2.5s grace timer starts relay recovery while an unauthenticated direct dial is still inside its 12s connect window; the race gets one attempt through the existing mutex/cooldown machinery, cancels when direct authenticates, and never arms for hosts without a relay endpoint. * fix(mobile): overlay the protocol gate instead of unmounting the host stack (F9) A pending status.get used to swap the mounted HostStack for a spinner at the moment the socket connected, destroying in-flight nested navigation. Once children have rendered for a host they stay mounted under an opaque touch-blocking overlay; first visits and blocked verdicts keep the old behavior. * fix(mobile): keep loaded data through transient connection blips (F10) Git history no longer blanks on reconnect (and commit files refetch instead of caching an offline empty answer), the repo picker keeps its last-good list when an in-flight repo.list rejects, the diff review's ready-state preservation actually runs, and proven host capabilities survive a drop flagged unverified instead of being wiped. * feat(mobile): coordinate every home deep push and bounce dead resume targets (F4+F7+F8) Notification taps, the Accounts card, and host-edit now use the shared mount-then-replace transition (with a focused-route walker so root-layout scope works); the Resume card renders from the snapshot in a disabled state so its late arrival can't shift Tasks under the thumb; resume targets are validated against proven catalog data, and a session route whose worktree the host proves missing bounces to the host index with a notice banner instead of stranding on a dead screen. * test(mobile): cover the resume-target and notice policies (F7) Key notice dismissal by code so closing one banner cannot swallow a later, different one, and move the visibility rule into host-route-notice.ts where it is testable without a screen. Adds the missing units for F7's decision points: isResumeTargetConfirmedMissing (unproven catalog is silence, synthetic routes exempt), the validating last-visited reader, and the notice visibility rule. * fix(mobile): review-pass hardening for the gate overlay and diff preservation Adversarial review findings: the reader's hunk position now survives a connection blip (reset only on item change), the covered stack is hidden from TalkBack while the gate overlay is up, and the overlay's hit-test comment is scoped honestly to in-tree views (native-Modal drawers present above it — follow-up). * fix(mobile): CI + CodeRabbit review fixes for #12609 Move the findings doc under docs/ (root directory guard), drop two unused eslint-disable directives, and address review findings: an unproven snapshot seed can no longer downgrade a proven worktree catalog; a locally-aborted relay dial skips the director fallback; post-migration bookkeeping failures log instead of masquerading as dial failures (which could suspend the healthy session); the auth wait arms its timeout before subscribing; forwarded dial phases stop at close(); the legacy selector_not_found fallback requires runtime_error; the diff-loading effect depends on the fields it reads; and host-edit auto-cancellation is now pinned by a test. * fix(mobile): second review round — queued replacements, race fence, confirmed bounces A network-change replacement now survives the recovery mutex and cooldowns as a queued intent instead of being dropped or suspending a healthy session — only a failed dial or a dead probe tears one down. The happy-eyeballs migration withdraws when direct authenticated during the relay dial (first-authenticated-wins). A worktree bounce requires two consecutive host-proven misses, since a transient desktop repo-scan rejection answers selector_not_found for a live worktree. Background network flaps no longer wake a billed relay splice, the lifecycle foreground flag stays in sync, a screen unmount cancels only its own pending host-stack transition, and diff review keeps the loaded review when its reconnect refresh rejects. Extracted mobile-endpoint-nudge-router.ts and the establisher's dialEligible pass, and split the supervisor nudge tests, to stay under max-lines. * fix(mobile): satisfy the React Doctor changed-code gate Render-phase ref writes move into effects: the protocol gate's resolved/mounted latches now record committed outcomes only (a discarded children render can no longer count as mounted), and the bounce hook syncs its callback ref in an effect. Array<T> annotations become T[] in the extracted modules. * fix(mobile): keep the loaded diff when the reconnect refetch rejects (F10) The diff-loading hook's catch was the one path still erasing a ready diff — the same keepLoadedDiff guard its disconnect and loading branches already use, now pinned by a reject-after-ready test. * fix(mobile): process foreground revival nudges --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> |
||
|
|
3a67186623 |
fix: stop notification loss, credentialed cache reuse, and clipboard clobber (#11230)
* fix: stop notification loss, credentialed cache reuse, and clipboard clobber Mobile catch-up (#8591): fetchMissed swallowed the RPC failure while deliverLive kept advancing and persisting lastDeliveredSeq, so the next successful catch-up asked from above the abandoned range and the desktop cut it. Sessions are module-scope, so an unchanged epoch never resets it. Quarantine the watermark at the last contiguously-delivered seq and hold it there until some later catch-up actually drains — not just one retry. A batch cut short by a teardown quarantines at the last event it settled. Jira attachment cache: currentEpoch summed two independent counters, so a site at siteEpoch 1 read the same value before and after a global clear. The mid-flight guard passed and re-inserted credentialed image bytes that "disconnect all" had just purged — resident for the process lifetime since pruneExpired has no timer. One monotonic ticker, compared by max. Web copy fallback: the handler registered in the capture phase, so xterm's bubble-phase listener overwrote text/plain with the terminal selection afterwards; served was already true, so the copy reported success. Every Orca copy affordance over plain HTTP (Copy Pane ID, Copy Path, commit SHA, PR URL) pasted the terminal selection. Bubble phase with stopImmediatePropagation. Covers the secure-context retry branch too, which shares the same helper. * fix: roll back the persisted watermark on catch-up failure; cover stopImmediatePropagation Adversarial review of a98d7f4d5d found two gaps. 1. The quarantine clamped only writes made AFTER the failure. getMissedSince waits up to 30s, so a live event routinely persists a higher seq while the request is still outstanding; that value stayed on disk, and the next launch read it back and resumed past the abandoned range -- the original bug, reached through a restart. quarantineCatchUpWatermark now re-persists the clamped seq, so the stored value never outlives the gap it guards. 2. web-clipboard-copy-terminal-selection's second test registered its "late" document handler BEFORE the fallback's, so it lost on registration order alone and stopImmediatePropagation was never exercised -- the test passed with that line deleted. Bubbling reaches the document before the window, so a window-level listener is what actually requires it. * fix(mobile): mark a notification seen only once its show lands A pre-marked seen key made a rejected show unrecoverable: the next catch-up re-fetched the seq and the dedup guard dropped it, and the first later event to drain the batch lifted the quarantine past it. Also contains the rejection so it does not escape the un-awaited 'ready'/live handlers as an unhandled rejection. Co-authored-by: Orca <help@stably.ai> * test(web-clipboard): pin stopImmediatePropagation with a same-target handler Both existing cases passed with plain stopPropagation, and with the listener back in the capture phase — neither half of the fix was actually pinned. The window-level clobber is on a different target, so stopPropagation suppresses it too. Registering the clobber on the document, ordered after the fallback's own listener, is the only shape stopPropagation cannot cover. Addresses the review comment posted after the last commit. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
d547e278f9 |
fix(mobile): deliver the notifications a reconnect missed, and never persist a watermark past them (#10816)
* fix(mobile): keep the reconnect watermark alive across the app's own teardown The catch-up added in #8690 could never run. app/index.tsx unsubscribes the notification stream on every non-'connected' state and builds a fresh subscription on reconnect, so the closure holding the ready-counter, the delivered watermark and the seen-set is destroyed exactly when a reconnect needs them. Every reconnect looked like a cold open, `reconnectReadyCount` was always 1, and notifications dispatched while the socket was down were never fetched. Move that state to a per-host module-scope session so it survives the teardown. Refs #8591 Co-authored-by: Orca <help@stably.ai> * fix(mobile): tag the notification watermark with a counter epoch so a desktop restart can't kill catch-up The desktop's notification `seq` is a per-process in-memory counter that starts at 0 on every launch. The mobile client's watermark is persisted in AsyncStorage and monotonic. After a desktop restart the two index different counters, so a client holding seq 57 meets a fresh counter at 2, `57 >= 2` cuts everything, and reconnect catch-up dies silently until the new process out-dispatches the old watermark — 57 notifications later. Users see nothing and get no error (#8591). Stamp every dispatched notification with an epoch identifying the counter lifetime, ride it on the `ready` frame and the getMissedSince response, and persist it beside the watermark. A watermark whose epoch doesn't match the live counter is void: the client resets to 0 and the desktop returns its retained buffer instead of nothing. The epoch param is optional on the wire in both directions, so a client or daemon that predates it degrades to today's seq-only cut rather than erroring. Also extracts the OS-permission helpers to notification-permissions.ts (re- exported, so no importer changes) to keep mobile-notifications.ts under its max-lines budget. Mutation-tested: 3 mutations applied to the epoch logic, 3 killed — including the storage-seed race guard, whose first mutant survived until the deferred-read test was added. * fix(mobile): make the notification watermark atomic and counter-scoped Round-1 review found four ways the epoch fix could still lose notifications. All four are addressed here. 1. Seen-set survived an epoch change. Seen-keys are seq-derived, and terminal bells carry no notificationId (they key on `seq:N` alone). After a restart the fresh counter re-issues low seqs, so a replayed post-restart bell was dropped as a duplicate of a bell from the previous counter. The dedup window belongs to one counter lifetime, so it is cleared on epoch change. 2. Legacy watermarks were trusted. Pre-upgrade installs stored a bare seq with no epoch. Adopting the first observed epoch as "nothing changed" left that unprovenanced seq cutting a counter it was never measured against — #8591 through the upgrade path. An epoch-less seq no longer survives adoption. 3. seq and epoch were separate storage keys. A process death between the two writes left epoch-B beside seq-57-from-A: a pair that looks internally valid on the next launch and is therefore trusted. They are now one JSON value, which cannot tear, with a read-only migration from the legacy key. 4. Sessions were never retired. They live at module scope so they survive the subscription teardown a reconnect performs, so host removal is the only thing that can drop them. Removal now retires the session and its watermark. Mutation-tested: 3 mutations, 3 killed. The first version of the bell test passed with the fix removed — it exercised the live path, which only adds to the seen-set; only the replay path consults it. Rewritten against the replay path, it fails with `expected 1 to be 2`: the literal lost notification. Mobile notifications + transport: 355 passed. Desktop replay: 11/11. * fix(mobile): catch up on the first connection after a cold open Catch-up hung off 'has this process connected before', which is false on the first ready of a fresh launch — exactly the post-upgrade / post-eviction case that loses everything between the stored watermark and the next live seq. Wait for the persisted read, then catch up whenever this device has delivered for the host before; a first-ever pairing still gets no replay. Co-authored-by: Orca <help@stably.ai> * fix(mobile): serialize live delivery behind the watermark seed, and key catch-up on the record Co-authored-by: Orca <help@stably.ai> * test(mobile): pin the two catch-up mechanisms mutation testing found unguarded Mutating each mechanism of the #8591 fix in turn showed two survived with the suite still green: the seed's epoch-provenance check, and the host session outliving the subscription teardown. Both are load-bearing, so pin them. - seen-set survives teardown: the desktop's retained buffer replays a notification already delivered live, and only the session-scoped seen-set stops a duplicate banner. - a seed resolving after a live epoch was adopted must not reinstate the dead watermark. Not reachable through subscribeToDesktopNotifications today ('ready' awaits the seed first), so it asserts on the exported pair and says so. Co-authored-by: Orca <help@stably.ai> * fix(mobile): serialize notification delivery per host so the watermark can't outrun what was shown Addresses two MAJOR findings from review of this branch. MAJOR #1 — the watermark could be persisted past a notification the user never saw. `deliverLive` advanced `lastDeliveredSeq` before awaiting the local show, and replay + live delivery ran concurrently, so a live seq 11 handled while catch-up was still showing seq 6 persisted 11. A process death before 7..10 were shown lost them permanently: the next launch asks the desktop for seq > 11. This predates the branch — `origin/main` advances the watermark at the same point — so it is a residual this fix closes, not a regression the branch introduced. It is fixed here because the branch is what makes the watermark load-bearing. Three changes: - the advance moves AFTER the show/dismiss await, so the watermark means "everything up to here reached the user" rather than "was dispatched" - a per-host `deliveryTail` promise chain (`enqueueHostDelivery`) serializes deliveries, so a monotonic advance is also an in-order one - the catch-up batch is ONE queue entry, not one per event. Awaiting per event returns to the event loop between replays and let a live event slot in between seq 6 and 7 — which is exactly the interleave being fixed. The RPC stays outside the queue: `sendRequest` waits up to 30s and holding the chain for that would stall live delivery on a slow link. MAJOR #2 — every delivery awaits the persisted read, so an AsyncStorage read that never settled disabled the host's notifications for the whole app lifetime, with no error and nothing to see. The seed is now bounded at 3s; a late seed still applies when it lands. Proceeding unseeded is strictly better: the watermark stays 0, so catch-up over-fetches and the seen-set de-duplicates. Serializing removed an overlap the duplicate-suppression relied on: `showLocalNotification` deduped two same-id events by observing the first still pending when the second arrived. With deliveries serialized the first completes first, so the second saw no pending state and scheduled a second banner for the same notification. The claim moves to enqueue time, where the overlap is still observable. Dismisses are deliberately not claimed — a dismiss for a shown id is what retires it. Evidence — each mechanism disabled individually against the unchanged suite: - batch-as-one-entry -> reverted to per-item enqueue: ordering test fails - watermark advance -> moved back before the await: ordering test fails - seed timeout -> removed: wedged-read test fails - live-path claim -> removed: concurrent-dedup test fails - replay-path claim -> removed: cross-path dedup test fails Each kills exactly one test, so no mechanism is unguarded and none is redundant. `mobile-notifications.test.ts`'s local `flushAsync` drained 10 microtask ticks. Deliveries are now several awaits deeper, so a fixed tick count under-drains; it yields to the macrotask queue instead. Verified with real timers that the behavior it asserts is unchanged — only the drain depth was wrong. Full mobile suite: 344 files, 2499 passed, 2 skipped. tsc clean, oxlint clean. --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
aab112933e |
Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
8f40ddf328 | fix(memory): bound OOM-prone accumulators (#10179) | ||
|
|
c6f0ac4040 |
refactor(comments): slim verbose comments in mobile (#9547)
Collapse multi-line explanatory comment blocks into single-line "why" statements
per AGENTS.md ("Document the Why, Briefly"): drop restatements of the code and
mechanism narration; keep the non-obvious reason, external refs, and directives.
Comments-only — verified no code changed via a Babel/esbuild comment-strip
token-equality gate against origin/main; typecheck and oxlint clean.
Area: mobile. 11 files changed, 339 insertions(+), 1137 deletions(-).
Co-authored-by: Orca <help@stably.ai>
|
||
|
|
d9f7fd08f7 | fix(mobile): harden notification opt-in onboarding (#8792) | ||
|
|
53c8a55833 |
Add mobile notification opt-in onboarding (#8780)
* feat(mobile): add notification opt-in onboarding * fix(mobile): deliver alerts despite desktop focus |
||
|
|
8e0977d295 |
fix(mobile): resync worktree list + idempotent notification replay on reconnect (#8498 #8129)
Take-over of #8605 (issue #8591). Ships #8498 (worktree resync + pull-to-refresh + cache write-through) and #8129 (idempotent notification replay on reconnect). Fixes the original PR's field mismatch (seq vs notificationSeq) and adds the missing notifications.getMissedSince mobile RPC allowlist entry. #6784 and #4500 held back to avoid conflicting with the relay work (#8536). Co-authored-by: Brandon Bennett (@branben). |
||
|
|
54c4959830 |
perf(mobile): cap the scheduled-notification map + tap-dedup set (#7646)
scheduledNotificationsByHostAndNotificationId (mobile-notifications.ts) retained one entry per scheduled desktop notification. The key embeds notificationId, which carries a per-completion timestamp (buildAgentNotificationId), so every agent-task-complete inserts a new, never-reused key. Entries are removed only when the desktop sends a matching dismiss — which a remote mobile user (not sitting at the desktop) frequently never receives — so the module-level map grew for the app's whole lifetime. Small per entry, but genuinely unbounded. Fix: bound the map to the 256 most-recent SETTLED entries (never evict one mid-schedule). A settled entry only retains a small identifier used for later programmatic dismissal, which is unnecessary for long-past completions, so eviction has no user-visible effect. Also FIFO-cap RootLayout's handledNotificationIdsRef tap-dedup Set (RootLayout never unmounts, so it otherwise grew one id per tapped notification forever). Test (red->green): with the cap at 1, scheduling a second notification evicts the first, so a later dismiss for the evicted id is a no-op while the retained one still dismisses; without the cap the old entry survives. |
||
|
|
c9ab6018da |
Fix duplicate mobile notifications (#5206)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
8959535f1b |
Auto-dismiss agent notifications on acknowledgement (#4518)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
0bb62b9d29 | Add braces to mobile control flow (#4351) | ||
|
|
be2dfe4bac | fix: drop mobile notification stream on cleanup (#2887) | ||
|
|
9562d9776f |
Fix mobile notification tap routing (#1871)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
9c24425504 |
fix(mobile): handle notification permission changes (#1506)
Co-authored-by: Orca <help@stably.ai> |
||
|
|
fc578f5ea9 |
feat(mobile): Expo companion app [beta] (#1245)
Co-authored-by: Orca <help@stably.ai> |