Commit Graph
8 Commits
Author SHA1 Message Date
Neil 1cc2a2afd8 refactor(shared): group remote runtime shared-control lifecycle (#32977) 2026-10-23 17:04:58 -07:00
Neil c30753c739 fix(runtime): show "Re-pair required" for revoked servers and re-pair in place (#30271)
* fix(runtime): show Re-pair required for revoked servers and re-pair in place

A server that revokes this computer's pairing token was labeled "Update
required" because auth refusals and protocol mismatches shared one blocked
state. Carry the blocked reason locally, label revoked hosts "Re-pair
required", and add a Re-pair action (sidebar host menu + Settings server
row) that verifies a new pairing link reaches the same server before
replacing the saved pairing.

* fix(sidebar): stop host header menu clicks from toggling or dragging the row

* fix(sidebar): vertically center host header label and menu button
2026-10-18 01:55:08 -07:00
Jinwoo Hong e9928c3542 feat(mobile): let the desktop list its servers and their workspaces for the phone (#26442)
* feat(mobile): show SSH host health on the workspace row host badge

Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now
adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared
function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge.

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

* feat(mobile): name an unhealthy SSH host even in a single-host list

The desktop card shows SSH status on any host count, so the phone now reads target summaries
whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one
target-to-host keying, and a hook-level test pins the reply-to-row wiring.

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

* fix(mobile): clear stale SSH host badges and announce them without the dot

A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps,
so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox,
Disconnected") instead of reading the middle dot.

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

* feat(runtime): let a paired desktop register its phones as delegated mobile devices

A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of
its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the
desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant.

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

* refactor(runtime): move the mobile device revoke primitive below dispatch

Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in
a layer under it as a pure move; this drops the abstract method seam. No behaviour change.

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

* fix(runtime): keep a runtime grant until its delegated phones are revoked

A failed child cleanup now leaves the parent in place so a retry can still find the children, and
revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage,
shares credential minting, and drops the timing-dependent census.

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

* fix(runtime): fail a delegated sync whose dropped phone cannot be revoked

Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors
before minting anything, so the desktop retries instead of believing the server matches its list.

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

* feat(mobile): relay a paired phone's server-workspace requests through the desktop

A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop
now forwards a request that names `executionHost: runtime:<env>` to that server over one socket per
(phone, server), signed in as the phone's delegated device; replies pass through untouched except
stream ids. Hidden until the phone client ships: the relay capability is not advertised yet.

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

* test(mobile): type the single-host health fixtures inline so the test tsc passes

Fixtures hoisted into consts widened their health maps to string, which the now-required
hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union.

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

* docs(mobile): state the desktop relay contract in its entry module

main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a
server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a
later request syncs it again.

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

* test(mobile): pin the relay's configured-server adapter

The production hosts adapter was only typechecked; this pins that it resolves configured servers
by exact id only (never a name or a removed server), fences by pairing revision and runtime
identity, and stops reporting retirements once unsubscribed.

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

* test(mobile): re-record the repo-metadata goldens for SSH host health

The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the
target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts.

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

* refactor(mobile): tighten the desktop relay after elegance review

Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request
whose link ended while opening, passes failure codes through, relays only this desktop's own
phones, and drops the unused host-state reader until S2 needs one.

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

* fix(mobile): bound relay frames and stop sticky relay verdicts

Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on
each new link so an in-place upgrade relays, answers a phone the server just declined without
syncing again, and ends a link whose mirrored capability update cannot be queued.

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

* fix(mobile): run targeted desktop-owned calls on the desktop

The phone tags every call on a workspace screen with its execution host, so a
paired-desktop method carrying `runtime:<env>` now runs locally instead of
failing 'forbidden'. The Mac stays the only reader of the routing table.

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

* feat(mobile): list the desktop's configured servers and their last workspace rows for the phone

The phone needs the same host list the desktop shows, including servers that are offline or
too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and
its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies.

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

* refactor(mobile): list server rows through one desktop fetch, with no side effect on listing

The phone now always lists a server's rows through mobileRelay.hosts.worktrees, so the
desktop's own fetch is both the live source and the cache, and stamping stays in main.

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

* fix(mobile): fetch a server's full workspace list within a bound, and forget removed servers

The desktop now asks for the phone's full-catalog limit, answers within 5s with the last rows
when a server hangs, drops a server's rows when it is removed or re-paired, and checks the
server's identity after the fetch so a reply from before a re-pair is never served.

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

* fix(mobile): list managed servers as the desktop sidebar does, and hide rows mid-delete

A server Orca manages on an SSH host now lists once under that host's name, or not at all while
the host is reached over the SSH relay, using the sidebar's own folding. Rows the server is
deleting are left out at reply time, as a phone asking the server itself would see.

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

* fix(mobile): keep a disconnected server's last rows, and never join a re-paired server's fetch

Retirement also fires on a manual disconnect or managed-server update, which wrongly emptied
the phone's list; rows are now pruned only once a server is no longer configured. Fetches are
shared per server identity, so a poll after a re-pair asks the new server right away.

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

* fix(ci): run the remote-runtime WebSocket opener test on Node

Under the bun vitest project, `ws` resolves to Bun's built-in client, which
ignores maxPayload, so the frame-cap test hung. The opener only ships on
Node/Electron, so run its test in the Node runtime project like its siblings.

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

* refactor(mobile-relay): derive the phone allowlist from the routing table

Adding a phone method now takes one edit (its route). A server-targeted call on a
legacy non-E2EE socket is refused instead of running locally, and only
execution-host methods enter the relay.

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

* refactor(mobile-relay): drop unread freshness fields from server workspace lists

The phone reads only the rows, so stale, fetchedAt, totalCount and truncated
were dead wire weight. The desktop still serves a server's last rows while
the server is unreachable.

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

* fix(runtime): classify the mobileRelay RPC namespace as host-owned

main now requires every runtime RPC namespace in the host-operation ownership table; the relay-hosts methods are served by the desktop the phone is paired to.
2026-10-11 03:43:52 -04:00
Neil 96eb97aad6 fix(runtime): split the host-contact epoch out of the connection generation (#20359)
* fix(runtime): split the host-contact epoch out of the connection generation

`connectionGeneration` carried two meanings and one reader was always wrong.
holding the session mirror through an outage leaves its subscriptions stranded
and that edge was the only thing left to revive them. But the same value is the
mirror's cache key -- use-runtime-session-mirror-environment-key.ts keys the
subscription effect on it, every published frame is stamped with it, and
web-session-terminal-retirement-proof-ledger.ts drops retained proofs when it
moves. So the bump #20085 needed as a resubscribe signal re-keyed and rebuilt
the mirror after any brief flap, which is the #19647 symptom #19873/#20059 fix.

Measured first: with the reconnect bump deleted, an ended stream followed by
recovery issues zero resubscribes, and the mirror's subscribe call registers no
`onClose`, so main's terminal close is dropped. #20085's claim is true -- the
subscription really is dead after recovery -- so the trigger has to exist. It
just must not be the cache key.

Give each meaning its own value:

- `connectionGeneration` returns to identity only: a new runtime session, a
  re-pair, an explicit clear. A same-runtime return no longer moves it, so no
  stamp, fence or retained proof is invalidated by a flap.
- `hostContactEpoch` counts "the host answered again after we lost contact". It
  lives on the store entry and is read only as a dependency of the two
  subscription effects in use-web-session-tabs-sync.ts -- never passed to an
  installer, never part of `environmentKey`, so it cannot become a stamp.

`useRuntimeSessionMirrorEnvironmentKey` becomes
`useRuntimeSessionMirrorEnvironmentKeys`, returning `environmentKey` (identity)
and `resubscribeSignal` (the epoch edge) from the one target scan, so the hot
ownership scan is not doubled.

Each direction is pinned by its own test: removing the resubscribe dependency
fails only 'reinstalls both session-tabs subscriptions when the host answers
again'; restoring the reconnect bump fails only the two key-stability tests.

* test(runtime): pin the mirror hydration verdict across a host flap

The generation tests assert the key string; this asserts what the user feels.
The mirror's hydration verdict is stamped with the connection generation, so any
bump discards it and every mirrored pane re-parks -- the tab-list rebuild. Held
across an unverifiable probe, still discarded when the runtime id actually moved.

* test(runtime): build real host statuses instead of casting partials
2026-09-17 02:07:52 -07:00
Neil 851befa929 fix(runtime): hold parking and transport reads through an unverifiable probe (#20096)
* fix(runtime): hold parking and transport reads through an unverifiable probe

Three remaining sites where a non-verified status probe was read as evidence the
host is gone, per docs/reference/ssh-execution-boundary.md.

- runtime-status-refresh published its own copy of the "null the status unless
  verified" rule, then handed the snapshot to applyRuntimeHostStatusSnapshot,
  which re-derived it. The copy was dead but free to drift; the snapshot branch
  now calls applyRuntimeHostStatusSnapshot directly, leaving one implementation.
- The paired-parking capability reads treated a nulled status as "host cannot
  park", so a transient probe failure unparked live paired terminals and dropped
  a parked session's reattach in favour of a fresh cold restore. Both now read
  lastVerifiedRuntimeStatus; a capability is a fact about the host's build.
- runtimeHostConnectionStateForEntry handled transport 'disconnected' and 'ready'
  and let 'connecting'/'unknown' fall through to the default 'disconnected' —
  reporting a host mid-handshake as down, a worse verdict than an actually
  disconnected transport gets. It now passes the snapshot's transport through.

* fix(runtime): keep a revoked host out of the parking promise

Holding a capability through an unverifiable probe is right; holding it through
the host's own refusal is not. `blocked` (auth rejected, protocol mismatch) stops
every retry for good, and parking trades the client's only copy of the scrollback
for a host-side restore that can then never happen -- the destructive direction.
`isRuntimeHostContactRevoked` names that one terminal verdict once, and the
connection-state derivation now reads it too so there is a single definition.

Also narrows the transport hint to 'connecting'. 'unknown' means no transport was
ever attempted, which is the permanent state of an unreachable paired host: as
'checking' its row lost its Connect action and the status bar read "connecting"
for the whole session.

* test(runtime): pin the parking gate against over-firing on a flap
2026-09-17 02:07:13 -07:00
Neil 67dda9affe fix(runtime): hold the session mirror through an unverifiable probe (#20085)
* fix(runtime): hold the session mirror through an unverifiable probe

Two derivations read the same host state and reached opposite verdicts, and
the destructive one won. When a status probe came back unverifiable over a
still-ready transport, runtimeHostConnectionStateForEntry called the host
'runtime-unavailable' (connected) while getReachableRuntimeSessionMirrorTargets
dropped it, tearing down and cold-rebuilding the session-tab mirror while the
host's flows were still delivering.

The root cause is that applyRuntimeHostStatusSnapshot nulls entry.status for any
non-verified probe while the snapshot retains the runtime identity. The
connection-state reader consults the snapshot; the mirror-target reader did not.

Give both readers one answer:

- lastVerifiedRuntimeStatus() in shared/runtime-host-status.ts is now the single
  definition of "the last identity the host answered with". runtime-status.ts
  already had this inline as previousVerifiedStatus and now calls it.
- The mirror-target reader asks the shared connection verdict instead of
  entry.status, gated on isDisconnectedRuntimeHostState -- only the one exit
  verdict earns a destructive read, per docs/reference/ssh-execution-boundary.md.
  'checking' and 'reconnecting' are unverifiable, not evidence of an exit.

Holding the target through the outage would strand the mirror on its own: the
subscription is installed by the effect in use-web-session-tabs-sync.ts keyed on
useRuntimeSessionMirrorEnvironmentKey(), a stream 'end' frame is dropped without
resubscribing, and the parking layer retries only a rejected subscribe call. The
teardown was the recovery. So regaining contact now advances the connection
epoch, giving recovery its own "the host is back" trigger rather than leaving the
mirror to be restored as a side effect of having been destroyed.

The connection epoch is not the runtime session: a same-runtime return fires no
restart hook, no provider session bump, and no toast.

* test(runtime): drop the redundant status casts the new casting gate rejects
2026-09-17 02:02:27 -07:00
Neil 779667c1e7 refactor(runtime): declare the host-status entry once instead of per consumer (#20262) 2026-09-17 00:11:45 -07:00
Jinwoo HongandOmar Shahine 3b82d8de64 fix(runtime): let connections own host status recovery (#20003)
* fix(runtime): let connections own host status recovery

Verify runtime status after authenticated connection recovery and publish
ordered snapshots to desktop and browser viewers. Consolidate failed-status
retries in the connection owner and remove renderer retry/diagnostics merging.

Adapt sidebar host-state derivation and regression coverage from Omar
Shahine's original fix in https://github.com/stablyai/orca/pull/19163.

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>

* fix(runtime): show blocked hosts honestly and remove obsolete status options

* fix(runtime): preserve timeout guidance and update IPC test fixtures

* fix(runtime): preserve status evidence and address review gaps

* test(sidebar): assert workspace host icons dimming and recovery tooltips

* fix(palette): require available hosts before adding implicit badges

* fix: retain disconnected host snapshots for new renderers

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
2026-09-11 03:19:01 -04:00