mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/mobile-pair-auth-surface
# Conflicts: # src/main/runtime/runtime-rpc.ts
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
ARG BASE_IMAGE=ubuntu:24.04
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG LIBASOUND_PACKAGE=libasound2t64
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
dbus-x11 \
|
||||
jq \
|
||||
libatk-bridge2.0-0 \
|
||||
libatspi2.0-0 \
|
||||
"${LIBASOUND_PACKAGE}" \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnss3 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
libxss1 \
|
||||
procps \
|
||||
util-linux \
|
||||
xauth \
|
||||
xvfb \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash orca
|
||||
|
||||
COPY config/docker/headless-pairing/run-appimage-case.sh /usr/local/bin/run-appimage-case
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/run-appimage-case"]
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM node:24-bookworm
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatspi2.0-0 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnss3 \
|
||||
libsecret-1-dev \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
libxss1 \
|
||||
pkg-config \
|
||||
python3 \
|
||||
rpm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable \
|
||||
&& corepack prepare pnpm@10.24.0 --activate
|
||||
|
||||
WORKDIR /workspace
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
case_name=${1:?launch case is required}
|
||||
appimage=${ORCA_TEST_APPIMAGE:-/artifacts/squashfs-root/AppRun}
|
||||
timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-12}
|
||||
pairing_address=${ORCA_PAIRING_ADDRESS:-127.0.0.1}
|
||||
port=${ORCA_SERVE_PORT:-0}
|
||||
state_dir="/tmp/orca-${case_name}"
|
||||
|
||||
if ((EUID == 0)); then
|
||||
mkdir -p "$state_dir/config" "$state_dir/cache"
|
||||
chown -R orca:orca "$state_dir"
|
||||
# Why: packaged serve should exercise the same unprivileged account required by production systemd guidance.
|
||||
exec runuser --user orca --preserve-environment -- "$0" "$@"
|
||||
fi
|
||||
|
||||
mkdir -p "$state_dir/config" "$state_dir/cache"
|
||||
|
||||
if [[ "$appimage" == *.AppImage ]]; then
|
||||
is_appimage=1
|
||||
launcher=("$appimage" --appimage-extract-and-run)
|
||||
else
|
||||
is_appimage=0
|
||||
launcher=("$appimage")
|
||||
fi
|
||||
|
||||
app_args=("${launcher[@]}")
|
||||
if [[ ${ORCA_TEST_NO_SANDBOX:-1} == 1 ]]; then
|
||||
app_args+=(--no-sandbox)
|
||||
fi
|
||||
app_args+=(serve --port "$port" --pairing-address "$pairing_address")
|
||||
if [[ ${ORCA_READY_JSON:-0} == 1 ]]; then
|
||||
app_args+=(--json)
|
||||
fi
|
||||
if [[ ${ORCA_NO_PAIRING:-0} == 1 ]]; then
|
||||
app_args+=(--no-pairing)
|
||||
fi
|
||||
|
||||
case "$case_name" in
|
||||
direct)
|
||||
command=("${app_args[@]}")
|
||||
;;
|
||||
xvfb)
|
||||
command=(xvfb-run -a "${app_args[@]}")
|
||||
;;
|
||||
dbus-xvfb)
|
||||
command=(dbus-run-session -- xvfb-run -a "${app_args[@]}")
|
||||
;;
|
||||
journal)
|
||||
command=(setsid --wait xvfb-run -a "${app_args[@]}")
|
||||
;;
|
||||
*)
|
||||
echo "Unknown launch case: $case_name" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
export HOME="$state_dir"
|
||||
export XDG_CONFIG_HOME="$state_dir/config"
|
||||
export XDG_CACHE_HOME="$state_dir/cache"
|
||||
if [[ $is_appimage == 0 ]]; then
|
||||
export APPDIR=${ORCA_TEST_APPDIR:-"$(dirname "$appimage")"}
|
||||
fi
|
||||
|
||||
if [[ ${ORCA_KEEP_RUNNING:-0} == 1 ]]; then
|
||||
exec "${command[@]}"
|
||||
fi
|
||||
|
||||
set +e
|
||||
timeout --signal=TERM --kill-after=2s "${timeout_seconds}s" "${command[@]}"
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
if ((status == 124)); then
|
||||
echo "STARTUP_TIMEOUT: no ready contract after ${timeout_seconds}s" >&2
|
||||
fi
|
||||
exit "$status"
|
||||
+131
-17
@@ -171,7 +171,8 @@
|
||||
"headless orca serve",
|
||||
"single-instance desktop activation",
|
||||
"CLI open",
|
||||
"persistent terminal reattach"
|
||||
"persistent terminal reattach",
|
||||
"update install handoff"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
@@ -191,18 +192,23 @@
|
||||
"daemon",
|
||||
"ssh"
|
||||
],
|
||||
"coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, and SSH identity transfer. A macOS Electron journey starts one headless owner in an isolated profile, creates and writes to a daemon PTY, activates the GUI through a second process, and verifies the original owner/runtime/daemon/PTY identities plus pre- and post-promotion I/O. Live packaged, Linux, and Windows journeys remain uncollected.",
|
||||
"coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, SSH identity transfer, and the macOS serve update handoff from staged installer through atomic bundle replacement and target-version readiness. A macOS Electron journey covers headless promotion and persistent PTY identity. A disposable locally signed Electron canary exercised real ShipIt and a temporary LaunchAgent with the compiled production supervisor; full packaged Orca and Linux/Windows serve updates remain uncollected.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8457"
|
||||
"https://github.com/stablyai/orca/issues/8457",
|
||||
"https://github.com/stablyai/orca/issues/9563"
|
||||
],
|
||||
"invariant": "A safely promotable headless serve process is the single app owner. Desktop activation opens a window in that same process only after the persistent PTY provider and runtime RPC are ready; every live persisted local or SSH terminal remains bound to the same PTY/session, and a committed Cmd+Q still exits the promoted app.",
|
||||
"oracle": "Unit tests coalesce early activation, fail closed on a fallback local provider, preserve the production single-instance path for serve, expose explicit desktop window state to local and remote clients, persist headless tab/leaf bindings, transfer local and SSH reattach metadata, and retain quit intent. The Electron journey asserts one main-process PID, runtime id, daemon PID, and PTY id across activation; confirms output written before promotion is visible afterward; confirms new terminal input still works; and requires the activating second process to exit.",
|
||||
"invariant": "A safely promotable headless serve process is the single app owner. Desktop activation preserves its daemon-backed sessions. On macOS, a CLI-supervised serve update keeps the node-mode parent alive across ShipIt's atomic bundle swap, restarts with the original serve arguments only after the target bundle is present, and clears handoff state only after that target version reports runtime readiness. Unsupported or failed handoffs leave the current serving owner intact or recover it once without an install retry loop.",
|
||||
"oracle": "Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/launch.test.ts src/main/serve-update-handoff.test.ts src/main/updater.headless-serve-install.test.ts src/main/updater.test.ts src/main/updater.mac-install.test.ts src/main/window/attach-main-window-services.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
"pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/updater.headless-serve-install.test.ts",
|
||||
"src/main/serve-update-handoff.test.ts",
|
||||
"src/cli/runtime/launch.test.ts",
|
||||
"src/main/startup/serve-desktop-activation.test.ts",
|
||||
"src/main/startup/serve-desktop-activation-wiring.test.ts",
|
||||
"src/main/startup/single-instance-lock.test.ts",
|
||||
@@ -213,6 +219,35 @@
|
||||
"tests/e2e/headless-serve-desktop-activation.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/updater.headless-serve-install.test.ts",
|
||||
"assertions": [
|
||||
"a ready update in headless serve is deferred before native install, paired-client disconnect, or active-session cleanup",
|
||||
"a supervised serve persists handoff after checkpoints but before native quit and uses no native GUI relaunch",
|
||||
"unsupported serve refuses updater staging and install-on-quit while preserving availability checks",
|
||||
"a failed handoff preflight preserves the serving owner before native quit or PTY cleanup",
|
||||
"macOS installer-readiness timeout cannot quit a headless serving owner",
|
||||
"ordinary macOS app quit is not reinterpreted as an install request in headless serve mode",
|
||||
"repeated requests emit one deterministic status and lifecycle diagnostic while interactive installs remain unchanged"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/cli/runtime/launch.test.ts",
|
||||
"assertions": [
|
||||
"the CLI parent remains alive after the old serving child exits instead of letting launchd respawn it",
|
||||
"an atomic app-bundle replacement starts one target-version serve child with the original arguments",
|
||||
"handoff completes only after the replacement reports target-version runtime readiness",
|
||||
"a replacement version mismatch or readiness timeout is persisted and exits without an in-process retry loop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/serve-update-handoff.test.ts",
|
||||
"assertions": [
|
||||
"install intent and failure state are written atomically under canonical user data",
|
||||
"an injected handoff path outside canonical user data cannot authorize an update",
|
||||
"a target-version startup clears stale failure state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/startup/serve-desktop-activation.test.ts",
|
||||
"assertions": [
|
||||
@@ -272,6 +307,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-21",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/launch.test.ts src/main/serve-update-handoff.test.ts src/main/updater.headless-serve-install.test.ts src/main/updater.test.ts src/main/updater.mac-install.test.ts src/main/window/attach-main-window-services.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5,
|
||||
"summary": "Seven focused files passed with 133 tests. The lifecycle harness keeps the CLI parent alive across an atomic .app replacement, starts one target-version serve replacement, and requires its bounded readiness message. Unsupported and failed-preflight paths make zero native install and PTY-cleanup calls; supervised native install leaves the modeled daemon session intact and suppresses native GUI relaunch."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-13",
|
||||
"runner": "local",
|
||||
@@ -301,11 +345,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The first Electron red run proved that bypassing the dev single-instance lock created a second owner. After enforcing one owner, the next red run retained owner/runtime/daemon identity but exposed a new PTY because the headless tab/leaf binding was not persisted. The final implementation persists that binding before renderer hydration and both subsequent Electron runs kept the original PTY and transcript. Focused unit tests were also observed red before the activation gate, promotion metadata transfer, and headless spawn-persistence changes were added."
|
||||
"evidence": "The original updater regression was observed red with one native install call, one paired-client disconnect, one cleanup start, no replacement owner, and a stranded staged installer. The root-cause harness was then observed red because the Electron child received no handoff path and the CLI parent exited, allowing launchd to spawn the old version while ShipIt still required zero running target apps. A live canary then exposed MacUpdater ignoring quitAndInstall relaunch arguments and starting a second desktop owner; disabling its independent relaunch for supervised mode produced one stable LaunchAgent parent, one verified replacement, and a surviving session across the real ShipIt swap. The final deterministic harness keeps that parent, observes the atomic bundle swap, and verifies the new serving version before clearing state. Earlier activation evidence also fixed second-owner and replacement-PTY failures."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "Activation adds no polling in the app owner and performs one bounded pass over live PTY records plus persisted terminal bindings only when a headless owner opens its first window. Desktop-only bell, command, PR-link, and mode scanners are rebuilt only while a real renderer graph is ready, preserving the prior pure-headless output path. CLI open keeps its existing 250ms bounded startup poll."
|
||||
"required": true,
|
||||
"evidence": "Normal serve and desktop paths add only constant-time mode checks plus one IPC listener on the macOS CLI child. During an actual install handoff only, the CLI watches the stable app parent directory and performs a bounded 250ms version-file poll for at most 120 seconds; there are no subprocesses, network calls, provider scans, or startup waits. Activation performance is unchanged."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.",
|
||||
@@ -314,6 +358,7 @@
|
||||
],
|
||||
"knownGaps": [
|
||||
"The Electron journey uses an isolated development bundle rather than the installed application so it cannot disturb a real user session.",
|
||||
"The live ShipIt/LaunchAgent canary used a disposable minimal Electron bundle plus the compiled production supervisor; a full packaged Orca update has not yet been run.",
|
||||
"Linux and Windows single-instance activation have unit coverage but no live Electron evidence yet.",
|
||||
"SSH identity transfer is deterministic unit coverage only; the live Electron journey currently exercises the local daemon provider."
|
||||
],
|
||||
@@ -1330,7 +1375,7 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-20",
|
||||
"date": "2026-07-21",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts",
|
||||
@@ -2385,7 +2430,7 @@
|
||||
},
|
||||
{
|
||||
"id": "terminal-provider.ssh-remote-reattach-contract",
|
||||
"title": "SSH and remote terminal restore never turn unknown liveness into destructive cleanup",
|
||||
"title": "SSH restore and remote-runtime recovery preserve live terminal ownership",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-provider",
|
||||
@@ -2393,6 +2438,8 @@
|
||||
"surfaces": [
|
||||
"SSH deferred restore",
|
||||
"remote-runtime mirror polling",
|
||||
"remote-runtime network recovery",
|
||||
"terminal create idempotency",
|
||||
"provider listing",
|
||||
"reattach",
|
||||
"unknown liveness"
|
||||
@@ -2414,7 +2461,7 @@
|
||||
"ssh",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, remote or unresolved hosts cannot enter local-daemon cold activation deferral, stale cleanup cannot unregister a replacement runtime terminal, and a mounted remote-runtime web mirror replaces a stale handle without retiring its pane or accumulating obsolete PTY identities. Bounded polling falls back to accepted host snapshots without a global store subscription, and concurrent panes share each in-flight inventory request. A macOS Electron journey connects to a real Linux SSH relay in Docker, persists six terminals, reloads the renderer, and verifies all six original relay PTYs remount eagerly and still execute remote input. Linux and Windows desktop clients, WSL, and live remote-runtime restore remain gaps.",
|
||||
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, stale cleanup cannot unregister a replacement runtime terminal, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Capability-gated create retries adopt a provider-owned PTY by stable terminal identity after an unknown outcome or runtime-process restart, stop retrying after one minute without a fatal error, and remain manually retryable without accepting stale create completions. A macOS Electron journey covers live SSH restore; a Windows remote-runtime smoke covers reachability and PTY round-trip. A live partition journey using patched Mac and Windows builds remains a gap.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6951",
|
||||
"https://github.com/stablyai/orca/pull/6955",
|
||||
@@ -2422,10 +2469,11 @@
|
||||
"https://github.com/stablyai/orca/pull/7009",
|
||||
"https://github.com/stablyai/orca/pull/8597"
|
||||
],
|
||||
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Snapshot-backed cold activation deferral is local-daemon-only; every restored remote terminal must mount a manager eagerly and preserve its provider PTY identity. A stale remote-runtime mirror handle must keep its pane mounted until a replacement or explicit terminal-removal fact arrives, and each replacement must atomically supersede rather than accumulate PTY identity.",
|
||||
"oracle": "Deterministic tests assert startup publishes ssh.connect's authoritative state before terminal reconnect, only an explicit local execution host can defer cold activation, host ownership changes clear old restrictions, replacement runtime registrations survive stale cleanup, stale mirror polling is bounded, and replacement handles resubscribe without exit/disconnect callbacks or stale PTY index growth. Count tests prove concurrent panes share one in-flight inventory request per runtime/worktree and accepted-snapshot listeners are identity-scoped and released after rebind. The live Docker SSH journey commits six terminal records to the relay, reloads Electron, requires the exact six manager ids with no parked tabs, compares all restored PTY ids to their pre-reload identities, and sends a marker through the restored terminal to a proof file on the Linux host.",
|
||||
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Every restored remote terminal must preserve its provider PTY identity. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.",
|
||||
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, and manual reconnect. They assert one unsubscribe per epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, one-minute create-retry cutoff, old-runtime no-retry behavior, authenticated client/worktree isolation, cross-process PTY adoption without rerunning startup, unavailable or legacy-incomplete inventory fail-closed behavior, and bounded in-flight coordination. Existing count tests prove concurrent panes share one in-flight inventory request per runtime/worktree and accepted-snapshot listeners are identity-scoped and released after rebind.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
@@ -2438,9 +2486,19 @@
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx",
|
||||
"src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts",
|
||||
"src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts",
|
||||
"src/renderer/src/runtime/web-session-terminal-handle-events.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
|
||||
"src/shared/remote-runtime-socket-liveness.test.ts",
|
||||
"src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
"src/shared/remote-runtime-shared-control-socket-generation.test.ts",
|
||||
"src/shared/remote-runtime-client-error-classification.test.ts",
|
||||
"src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
|
||||
"src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts",
|
||||
"src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
|
||||
"tests/e2e/terminal-cold-activation-deferral.spec.ts",
|
||||
"tests/e2e/ssh-cold-activation-restore.spec.ts"
|
||||
],
|
||||
@@ -2463,7 +2521,53 @@
|
||||
"assertions": [
|
||||
"a stale web-mirror handle polls until a different ready handle is published without resubscribing the stale handle",
|
||||
"replacement does not emit pane exit or disconnect callbacks and explicit terminal exit still retires the mirror",
|
||||
"replacement polling and each in-flight request share a 15-second deadline, then accepted snapshots own recovery without input re-arming polling"
|
||||
"replacement polling and each in-flight request share a 15-second deadline, then accepted snapshots own recovery without input re-arming polling",
|
||||
"cold restored-terminal subscription failure retries and resumes snapshot, output, and input without a fatal error",
|
||||
"a canonical close before subscription readiness opens exactly one replacement stream without surfacing a fatal error",
|
||||
"recovery exposes connecting, recovering, backoff, connected, and fatal-offline phases with fenced epochs",
|
||||
"ten partition cycles retain one PTY identity, reject detached input, and unsubscribe each epoch exactly once",
|
||||
"cached terminal pixels remain disconnected until authoritative replay completes",
|
||||
"automatic retries stop after one minute with no fatal error, no further requests, and no stale input",
|
||||
"manual reconnect starts a new epoch, resubscribes the same PTY exactly once, and becomes healthy only after its snapshot",
|
||||
"unknown terminal-create recovery stops all request activity after one minute without a fatal error, clips post-probe RPC timeouts to the remaining budget, then manual retry re-probes capability and reconciles the same mutation",
|
||||
"a stale create completion cannot replace or close a newer attachment, including when two runtimes use the same raw handle",
|
||||
"an authoritative capability-probe failure replaces the stale connectivity error"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts",
|
||||
"assertions": [
|
||||
"a healthy replacement invalidates the prior recovery epoch so a slower failure cannot re-arm retry",
|
||||
"the automatic-recovery deadline cancels pending backoff and a manual retry owns a new epoch",
|
||||
"a caller-owned recovery cutoff cancels scheduled work and remains disconnected"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx",
|
||||
"assertions": [
|
||||
"automatic recovery renders as a quiet bounded status without a button",
|
||||
"the disconnected state exposes one explicit Reconnect action"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts",
|
||||
"assertions": [
|
||||
"only actionable recovery phases retain UI state and pane closure removes disconnected state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/remote-runtime-socket-liveness.test.ts",
|
||||
"assertions": [
|
||||
"a suspended client receives a fresh probe deadline after resume and post-resume activity clears it"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
|
||||
"assertions": [
|
||||
"one authenticated client/worktree mutation derives a stable server-owned terminal handle",
|
||||
"an unknown-outcome retry adopts the same provider PTY after runtime-process restart without rerunning startup",
|
||||
"retry inventory failure, missing same-worktree identity metadata, or ownership mismatch fails closed without spawning",
|
||||
"in-flight mutation coordination is bounded and releases capacity after settlement"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -2513,6 +2617,15 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 15,
|
||||
"summary": "Ten provider-contract files and 566 tests passed, including a shared 15-second polling/RPC deadline, no input-triggered re-polling after the bound, post-timeout snapshot recovery, listener cleanup, in-flight inventory deduplication, and replacement-state migration."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-21",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5,
|
||||
"summary": "Eleven fault-injection and recovery-UI files and 127 tests passed, covering suspended heartbeat clocks, stale socket, PTY, and create generations, canonical pre-ready close recovery with one replacement subscription, cold and repeated PTY reattachment, authoritative health, bounded PTY and terminal-create recovery, post-probe timeout clipping, manually retryable create cutoff, accurate capability-probe failures, same-PTY manual reconnect, pane-state cleanup, fatal error deduplication, stable create identity, cross-process PTY adoption, and fail-closed legacy inventory."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -2525,11 +2638,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests assert SSH attach uses relay pty.attach for saved sessions, expired relay attach does not fresh-spawn in the provider, a failed SSH listProcesses observation does not clear previously learned ownership, a rejected SSH hasPty probe resolves as unknown liveness, deferred SSH passphrase cancellation does not auto-reconnect, deferred attach uses saved leaf/tab session ids once connected, transient deferred reattach failure preserves the saved session id without clearing pane/tab bindings or fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without clearing pane/tab bindings or consuming the saved restore id, disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn, and remote-runtime stale handles rotate in place without stale resubscription, pane retirement, or PTY index growth. Needs WSL, live remote-runtime/SSH, and saved intentional-break artifacts before promotion."
|
||||
"evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. The bounded-recovery tests additionally failed before the policy change because PTY and terminal-create recovery remained active after one minute and a retry reused the stale epoch. Final review tests failed before lifecycle fencing because create cutoff emitted a fatal error with no manual path and a delayed create completion replaced a newer cross-runtime attachment. The fixed tests pass with resumed snapshot/output/input, one-minute cutoffs, new manual epochs, capability re-probing, stale-create rejection, and cross-process provider PTY adoption without rerunning startup. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, a patched live remote-runtime partition journey, and saved intentional-break artifacts before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Host eligibility is resolved from existing store metadata during activation. Remote-runtime resubscribe polling runs only after a transport close, reserves replacement-only matching for typed stale-handle evidence, backs off from 150 ms to 1 s, caps each request to the remaining 15-second reconnect deadline, and shares each in-flight inventory request across panes in the same runtime/worktree. Accepted host snapshots use an identity-scoped listener only while recovery is pending; after the bound there is no timer or global store subscriber, and stale input cannot re-arm polling. The listener is released on rebind, removal, detach, or destroy, and PTY state is atomically replaced instead of growing. The local journey preserves cold deferral, while SSH eagerly mounts six managers as required; no work is added to typing or terminal-output hot paths."
|
||||
"evidence": "Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action; regular PTYs and initial creates do not poll inventory. Each unknown-outcome create attempt performs one bounded provider inventory scan, coordinated per authenticated client/worktree mutation, and the renderer stops issuing attempts after one minute. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch and cutoff tests prove request counts stay fixed for five additional minutes. Client and server liveness each use one interval per socket/transport. At most 4,096 create promises are retained only while in flight, and capacity rejection happens before spawning. Common terminal input/output paths add only constant-time state checks; recovery UI updates only on deduplicated phase transitions."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Use deterministic fake providers for failure and unknown-liveness cases.",
|
||||
@@ -2540,7 +2653,8 @@
|
||||
"Current command covers store wake-hint metadata, main-process SSH provider failure semantics, provider attach/expired-attach behavior, and renderer deferred SSH reconnect/transient-failure/expired-relay fallback with mocked transports.",
|
||||
"The live SSH journey is environment-dependent and currently runs from a macOS Electron client against a Linux Docker host.",
|
||||
"WSL restore remains inferred rather than directly covered.",
|
||||
"Linux and Windows desktop-client journeys and a live remote-runtime reconnect soak are not yet collected."
|
||||
"Linux and Windows desktop-client partition journeys using patched builds are not yet collected; the Windows smoke proves current reachability and PTY round-trip only.",
|
||||
"Terminal-create recovery depends on providers authoritatively listing live terminal handles and worktree ownership; older runtimes do not advertise the capability and are never retried after an unknown outcome."
|
||||
],
|
||||
"demotionRule": "Cannot promote if provider failure can close panes or if the oracle is screenshot-only."
|
||||
},
|
||||
|
||||
@@ -423,20 +423,38 @@ async function buildArtifacts() {
|
||||
// Why: released snapshots are the detection ground truth for existing installs,
|
||||
// so a generation-logic change must not rewrite them silently. Only the one
|
||||
// unreleased working-tree append per skill may change between runs.
|
||||
function assertReleasedHistoryPreserved(committedRegistry, artifacts) {
|
||||
function releasedSnapshotCountsFromMapping(releaseMapping) {
|
||||
if (!releaseMapping || releaseMapping.schemaVersion !== RELEASE_MAPPING_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
const counts = {}
|
||||
for (const release of releaseMapping.releases ?? []) {
|
||||
for (const [name, revision] of Object.entries(release.skills ?? {})) {
|
||||
counts[name] = Math.max(counts[name] ?? 0, revision)
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
function assertReleasedHistoryPreserved(committedRegistry, artifacts, committedReleaseMapping) {
|
||||
if (!committedRegistry || committedRegistry.schemaVersion !== SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
|
||||
return
|
||||
}
|
||||
const committedReleasedCounts = releasedSnapshotCountsFromMapping(committedReleaseMapping)
|
||||
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
|
||||
const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0
|
||||
const regenerated = artifacts.snapshotRegistry.skills[name] ?? []
|
||||
if (releasedCount < Math.max(0, committedSnapshots.length - 1)) {
|
||||
const minimumReleasedCount =
|
||||
committedReleasedCounts?.[name] ?? Math.max(0, committedSnapshots.length - 1)
|
||||
if (releasedCount < minimumReleasedCount) {
|
||||
throw new Error(
|
||||
`Released snapshot history is incomplete for ${name}. ` +
|
||||
'Fetch all release tags before regenerating skill artifacts.'
|
||||
)
|
||||
}
|
||||
const protectedCount = Math.min(committedSnapshots.length, releasedCount)
|
||||
const protectedCount = committedReleasedCounts
|
||||
? (committedReleasedCounts[name] ?? 0)
|
||||
: Math.min(committedSnapshots.length, releasedCount)
|
||||
for (let index = 0; index < protectedCount; index += 1) {
|
||||
const committed = committedSnapshots[index]
|
||||
const rebuilt = regenerated[index]
|
||||
@@ -458,6 +476,14 @@ async function readCommittedRegistry() {
|
||||
}
|
||||
}
|
||||
|
||||
async function readCommittedReleaseMapping() {
|
||||
try {
|
||||
return JSON.parse(await readFile(RELEASE_MAPPING_PATH, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function serialized(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`
|
||||
}
|
||||
@@ -532,7 +558,11 @@ async function verifyArtifacts(artifacts) {
|
||||
|
||||
async function main() {
|
||||
const artifacts = await buildArtifacts()
|
||||
assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts)
|
||||
assertReleasedHistoryPreserved(
|
||||
await readCommittedRegistry(),
|
||||
artifacts,
|
||||
await readCommittedReleaseMapping()
|
||||
)
|
||||
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,36 @@ describe('skill bundle manifest generator', () => {
|
||||
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
|
||||
})
|
||||
|
||||
it('protects only revisions named by the committed release mapping', () => {
|
||||
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
|
||||
const committedRegistry = {
|
||||
schemaVersion: 1,
|
||||
skills: {
|
||||
'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'unreleased-tail')]
|
||||
}
|
||||
}
|
||||
const artifacts = {
|
||||
releasedSnapshotCounts: { 'linear-tickets': 2 },
|
||||
snapshotRegistry: {
|
||||
schemaVersion: 1,
|
||||
skills: { 'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'new-release')] }
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(committedRegistry, artifacts, {
|
||||
schemaVersion: 1,
|
||||
releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 1 } }]
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertReleasedHistoryPreserved(committedRegistry, artifacts, {
|
||||
schemaVersion: 1,
|
||||
releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 2 } }]
|
||||
})
|
||||
).toThrow('Released snapshot history changed for linear-tickets at revision 2')
|
||||
})
|
||||
|
||||
it('tolerates only redundant trailing release-mapping rows', () => {
|
||||
const serialized = (value) => `${JSON.stringify(value, null, 2)}\n`
|
||||
const rows = [
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 20_000
|
||||
const APPIMAGE_EXTRACTION_TIMEOUT_MS = 60_000
|
||||
const commandArgs = process.argv.slice(2)
|
||||
const appImageArg = valueAfter('--appimage')
|
||||
const pairingOnly = commandArgs.includes('--pairing-only')
|
||||
if (!appImageArg) {
|
||||
fail('Usage: run-headless-linux-pairing-docker.mjs --appimage /path/to/orca.AppImage')
|
||||
}
|
||||
const appImage = resolve(appImageArg)
|
||||
if (!existsSync(appImage)) {
|
||||
fail(`AppImage not found: ${appImage}`)
|
||||
}
|
||||
|
||||
const suffix = `${process.pid}-${Date.now()}`
|
||||
const artifactVolume = `orca-headless-pairing-artifact-${suffix}`
|
||||
const network = `orca-headless-pairing-${suffix}`
|
||||
const containers = new Set()
|
||||
const images = [
|
||||
{
|
||||
name: 'ubuntu-24.04',
|
||||
tag: 'orca-headless-pairing:ubuntu-24.04',
|
||||
base: 'ubuntu@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90',
|
||||
libasound: 'libasound2t64'
|
||||
},
|
||||
{
|
||||
name: 'debian-13',
|
||||
tag: 'orca-headless-pairing:debian-13',
|
||||
base: 'debian@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd',
|
||||
libasound: 'libasound2t64'
|
||||
},
|
||||
{
|
||||
name: 'ubuntu-22.04-baseline',
|
||||
tag: 'orca-headless-pairing:ubuntu-22.04',
|
||||
base: 'ubuntu@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982',
|
||||
libasound: 'libasound2'
|
||||
}
|
||||
]
|
||||
|
||||
try {
|
||||
docker(['volume', 'create', artifactVolume])
|
||||
docker(['network', 'create', network])
|
||||
for (const image of pairingOnly ? images.slice(0, 2) : images) {
|
||||
buildImage(image)
|
||||
}
|
||||
extractAppImage(images[0].tag)
|
||||
if (!pairingOnly) {
|
||||
await validateStartupMatrix()
|
||||
await validateUnavailableContracts()
|
||||
}
|
||||
await validateAuthenticatedPairing()
|
||||
await validateUnreachableOffer()
|
||||
console.log('Headless Linux pairing Docker validation passed.')
|
||||
} finally {
|
||||
for (const container of containers) {
|
||||
docker(['rm', '-f', container], { allowFailure: true })
|
||||
}
|
||||
docker(['network', 'rm', network], { allowFailure: true })
|
||||
docker(['volume', 'rm', artifactVolume], { allowFailure: true })
|
||||
}
|
||||
|
||||
function valueAfter(flag) {
|
||||
const index = commandArgs.indexOf(flag)
|
||||
return index === -1 ? null : (commandArgs[index + 1] ?? null)
|
||||
}
|
||||
|
||||
function buildImage(image) {
|
||||
console.log(`Building ${image.name} fixture...`)
|
||||
docker([
|
||||
'build',
|
||||
'--build-arg',
|
||||
`BASE_IMAGE=${image.base}`,
|
||||
'--build-arg',
|
||||
`LIBASOUND_PACKAGE=${image.libasound}`,
|
||||
'-f',
|
||||
'config/docker/headless-pairing/Dockerfile',
|
||||
'-t',
|
||||
image.tag,
|
||||
'.'
|
||||
])
|
||||
}
|
||||
|
||||
function extractAppImage(image) {
|
||||
console.log('Extracting AppImage once without FUSE...')
|
||||
// Why: AppImage extraction creates a root-only directory, while launch cases intentionally run as the service user.
|
||||
docker([
|
||||
'run',
|
||||
'--rm',
|
||||
'--entrypoint',
|
||||
'bash',
|
||||
'-v',
|
||||
`${appImage}:/input/orca.AppImage:ro`,
|
||||
'-v',
|
||||
`${artifactVolume}:/artifacts`,
|
||||
image,
|
||||
'-lc',
|
||||
'cp /input/orca.AppImage /artifacts/orca.AppImage && chmod +x /artifacts/orca.AppImage && cd /artifacts && ./orca.AppImage --appimage-extract >/dev/null && chmod -R a+rX /artifacts/squashfs-root'
|
||||
])
|
||||
}
|
||||
|
||||
async function validateStartupMatrix() {
|
||||
const required = images.slice(0, 2)
|
||||
for (const image of required) {
|
||||
for (const launch of ['direct', 'xvfb', 'dbus-xvfb', 'journal']) {
|
||||
for (const mode of ['human', 'json']) {
|
||||
const result = await startAndWait({ image, launch, mode, address: 'pair-host.test' })
|
||||
validateReady(result.stdout, mode, 'pair-host.test', {
|
||||
allowStdoutNoise: launch !== 'direct'
|
||||
})
|
||||
stopContainer(result.name)
|
||||
console.log(`PASS ${image.name} ${launch} ${mode}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const baseline = await startAndWait({
|
||||
image: images[2],
|
||||
launch: 'xvfb',
|
||||
mode: 'json',
|
||||
address: '127.0.0.1'
|
||||
})
|
||||
validateReady(baseline.stdout, 'json', '127.0.0.1', { allowStdoutNoise: true })
|
||||
stopContainer(baseline.name)
|
||||
console.log(`PASS ${images[2].name} xvfb json`)
|
||||
|
||||
const publicEntry = await startAndWait({
|
||||
image: images[0],
|
||||
launch: 'direct',
|
||||
mode: 'json',
|
||||
address: '127.0.0.1',
|
||||
appPath: '/artifacts/orca.AppImage',
|
||||
startupTimeoutMs: APPIMAGE_EXTRACTION_TIMEOUT_MS
|
||||
})
|
||||
validateReady(publicEntry.stdout, 'json', '127.0.0.1', { allowStdoutNoise: true })
|
||||
stopContainer(publicEntry.name)
|
||||
console.log('PASS AppImage --appimage-extract-and-run --no-sandbox serve')
|
||||
}
|
||||
|
||||
async function validateUnavailableContracts() {
|
||||
const invalid = await startAndWait({
|
||||
image: images[0],
|
||||
launch: 'direct',
|
||||
mode: 'json',
|
||||
address: '0.0.0.0'
|
||||
})
|
||||
const invalidPayload = readyJson(invalid.stdout)
|
||||
assert(invalidPayload.pairing?.available === false, 'wildcard pairing must be unavailable')
|
||||
assert(
|
||||
invalidPayload.pairing.reason === 'invalid_advertised_endpoint',
|
||||
'wildcard pairing must expose invalid_advertised_endpoint'
|
||||
)
|
||||
stopContainer(invalid.name)
|
||||
|
||||
const disabled = await startAndWait({
|
||||
image: images[1],
|
||||
launch: 'journal',
|
||||
mode: 'json',
|
||||
address: '127.0.0.1',
|
||||
noPairing: true
|
||||
})
|
||||
const disabledPayload = readyJson(disabled.stdout)
|
||||
assert(disabledPayload.pairing?.reason === 'disabled_by_operator', 'disabled reason is missing')
|
||||
stopContainer(disabled.name)
|
||||
console.log('PASS explicit unavailable pairing contracts')
|
||||
}
|
||||
|
||||
async function validateAuthenticatedPairing() {
|
||||
const server = await startAndWait({
|
||||
image: images[0],
|
||||
launch: 'direct',
|
||||
mode: 'json',
|
||||
address: 'ws://orca-pairing-server:6768/runtime?route=runtime',
|
||||
port: '6768',
|
||||
networkAlias: 'orca-pairing-server'
|
||||
})
|
||||
const payload = readyJson(server.stdout)
|
||||
const client = runPairingClient(payload.pairing.url)
|
||||
assert(client.status === 0, `pairing client failed:\n${client.stderr}\n${client.stdout}`)
|
||||
const status = lastJsonObject(client.stdout)
|
||||
const statusResult = status?.result ?? status
|
||||
assert(
|
||||
statusResult?.runtime?.state === 'ready',
|
||||
`paired client did not report runtime.state=ready: ${JSON.stringify(status)}`
|
||||
)
|
||||
assert(
|
||||
statusResult?.reachable === true || statusResult?.runtime?.reachable === true,
|
||||
`paired client did not report reachable=true: ${JSON.stringify(status)}`
|
||||
)
|
||||
assert(
|
||||
statusResult?.runtime?.runtimeId === payload.runtimeId ||
|
||||
status?._meta?.runtimeId === payload.runtimeId,
|
||||
'paired client runtime ID does not match ready contract'
|
||||
)
|
||||
stopContainer(server.name)
|
||||
console.log('PASS authenticated E2EE pairing from an independent Debian container')
|
||||
}
|
||||
|
||||
async function validateUnreachableOffer() {
|
||||
const server = await startAndWait({
|
||||
image: images[0],
|
||||
launch: 'direct',
|
||||
mode: 'json',
|
||||
address: 'orca-pairing-server:6769',
|
||||
port: '6768',
|
||||
networkAlias: 'orca-pairing-server'
|
||||
})
|
||||
const payload = readyJson(server.stdout)
|
||||
const client = runPairingClient(payload.pairing.url)
|
||||
assert(client.status !== 0, 'a deliberately mismatched advertised port unexpectedly connected')
|
||||
stopContainer(server.name)
|
||||
console.log('PASS unreachable advertised endpoint fails the real client probe')
|
||||
}
|
||||
|
||||
async function startAndWait({
|
||||
image,
|
||||
launch,
|
||||
mode,
|
||||
address,
|
||||
port = '0',
|
||||
appPath = '/artifacts/squashfs-root/AppRun',
|
||||
networkAlias,
|
||||
noPairing = false,
|
||||
startupTimeoutMs = STARTUP_TIMEOUT_MS
|
||||
}) {
|
||||
const name = `orca-pairing-${suffix}-${containers.size}`
|
||||
const args = [
|
||||
'run',
|
||||
'-d',
|
||||
'--init',
|
||||
'--shm-size',
|
||||
'256m',
|
||||
'--name',
|
||||
name,
|
||||
'--network',
|
||||
network,
|
||||
...(networkAlias ? ['--network-alias', networkAlias] : []),
|
||||
'-e',
|
||||
'ORCA_KEEP_RUNNING=1',
|
||||
'-e',
|
||||
'LIBGL_ALWAYS_SOFTWARE=1',
|
||||
'-e',
|
||||
`ORCA_READY_JSON=${mode === 'json' ? '1' : '0'}`,
|
||||
'-e',
|
||||
`ORCA_PAIRING_ADDRESS=${address}`,
|
||||
'-e',
|
||||
`ORCA_SERVE_PORT=${port}`,
|
||||
'-e',
|
||||
`ORCA_TEST_APPIMAGE=${appPath}`,
|
||||
...(noPairing ? ['-e', 'ORCA_NO_PAIRING=1'] : []),
|
||||
'-v',
|
||||
`${artifactVolume}:/artifacts:ro`,
|
||||
image.tag,
|
||||
launch
|
||||
]
|
||||
docker(args)
|
||||
containers.add(name)
|
||||
const stdout = await waitForReady(name, startupTimeoutMs)
|
||||
return { name, stdout }
|
||||
}
|
||||
|
||||
async function waitForReady(name, startupTimeoutMs) {
|
||||
const deadline = Date.now() + startupTimeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const logResult = docker(['logs', name], { allowFailure: true })
|
||||
const stdout = `${logResult.stdout}${logResult.stderr}`
|
||||
if (hasCompleteReadyContract(stdout)) {
|
||||
return stdout
|
||||
}
|
||||
const running = docker(['inspect', '-f', '{{.State.Running}}', name], {
|
||||
allowFailure: true
|
||||
}).stdout.trim()
|
||||
if (running === 'false') {
|
||||
const containerLogs = docker(['logs', name], { allowFailure: true })
|
||||
throw new Error(
|
||||
`${name} exited before readiness:\n${containerLogs.stdout}${containerLogs.stderr}`
|
||||
)
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 100))
|
||||
}
|
||||
const logResult = docker(['logs', name], { allowFailure: true })
|
||||
throw new Error(
|
||||
`${name} did not emit readiness within ${startupTimeoutMs}ms:\n${logResult.stdout}${logResult.stderr}`
|
||||
)
|
||||
}
|
||||
|
||||
function hasCompleteReadyContract(stdout) {
|
||||
if (
|
||||
stdout.includes('Orca server ready\n') &&
|
||||
(stdout.includes('\nPairing URL: ') || stdout.includes('\nPairing guidance: '))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return readyJsonObjects(stdout).length > 0
|
||||
}
|
||||
|
||||
function validateReady(logs, mode, expectedHost, options = {}) {
|
||||
if (mode === 'human') {
|
||||
assert(
|
||||
(logs.match(/^Orca server ready$/gm) ?? []).length === 1,
|
||||
'human ready marker is not exact-once'
|
||||
)
|
||||
assert(logs.includes('Bound endpoint: ws://0.0.0.0:'), 'human bound endpoint is missing')
|
||||
assert(
|
||||
logs.includes(`Advertised endpoint: ws://${expectedHost}:`),
|
||||
'human advertised endpoint is missing'
|
||||
)
|
||||
assert(logs.includes('Pairing URL: orca://pair?code='), 'human pairing URL is missing')
|
||||
return
|
||||
}
|
||||
if (!options.allowStdoutNoise) {
|
||||
const stdoutLines = logs.split(/\r?\n/).filter(Boolean)
|
||||
assert(
|
||||
stdoutLines.length === 1,
|
||||
`JSON stdout must contain exactly one line, found ${stdoutLines.length}:\n${logs}`
|
||||
)
|
||||
}
|
||||
const payload = readyJson(logs)
|
||||
assert(payload.schemaVersion === 1, 'ready JSON schemaVersion is missing')
|
||||
assert(payload.endpoint === payload.boundEndpoint, 'legacy JSON endpoint alias is inconsistent')
|
||||
assert(payload.boundEndpoint?.startsWith('ws://0.0.0.0:'), 'JSON bound endpoint is invalid')
|
||||
assert(payload.advertisedEndpoint?.includes(expectedHost), 'JSON advertised endpoint is invalid')
|
||||
assert(payload.pairing?.available === true, 'JSON pairing offer is unavailable')
|
||||
assert(
|
||||
new URL(payload.boundEndpoint).port === new URL(payload.advertisedEndpoint).port,
|
||||
'an advertised host without a port must use the actual bound port'
|
||||
)
|
||||
}
|
||||
|
||||
function readyJson(logs) {
|
||||
const matches = readyJsonObjects(logs)
|
||||
assert(matches.length === 1, `expected one ready JSON object, found ${matches.length}`)
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
function readyJsonObjects(logs) {
|
||||
const marker = '{"type":"orca_server_ready"'
|
||||
return logs
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
const markerIndex = line.indexOf(marker)
|
||||
return markerIndex === -1 ? null : parseJson(line.slice(markerIndex))
|
||||
})
|
||||
.filter((value) => value?.type === 'orca_server_ready')
|
||||
}
|
||||
|
||||
function runPairingClient(pairingUrl) {
|
||||
return docker(
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--network',
|
||||
network,
|
||||
'--entrypoint',
|
||||
'/artifacts/squashfs-root/AppRun',
|
||||
'-e',
|
||||
'APPDIR=/artifacts/squashfs-root',
|
||||
'-v',
|
||||
`${artifactVolume}:/artifacts:ro`,
|
||||
images[1].tag,
|
||||
'--no-sandbox',
|
||||
'--pairing-code',
|
||||
pairingUrl,
|
||||
'status',
|
||||
'--json'
|
||||
],
|
||||
{ allowFailure: true, timeout: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
function lastJsonObject(output) {
|
||||
return parseJson(output.trim()) ?? output.split(/\r?\n/).map(parseJson).findLast(Boolean) ?? null
|
||||
}
|
||||
|
||||
function parseJson(value) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stopContainer(name) {
|
||||
docker(['stop', '--time', '5', name], { allowFailure: true })
|
||||
docker(['rm', name], { allowFailure: true })
|
||||
containers.delete(name)
|
||||
}
|
||||
|
||||
function docker(args, options = {}) {
|
||||
try {
|
||||
const stdout = execFileSync('docker', args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
stdio: options.allowFailure ? 'pipe' : ['ignore', 'pipe', 'inherit'],
|
||||
timeout: options.timeout
|
||||
})
|
||||
return { status: 0, stdout, stderr: '' }
|
||||
} catch (error) {
|
||||
const result = {
|
||||
status: typeof error.status === 'number' ? error.status : 1,
|
||||
stdout: String(error.stdout ?? ''),
|
||||
stderr: String(error.stderr ?? error.message)
|
||||
}
|
||||
if (!options.allowFailure) {
|
||||
throw new Error(`docker ${args[0]} failed:\n${result.stderr}\n${result.stdout}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(message)
|
||||
process.exit(2)
|
||||
}
|
||||
@@ -260,7 +260,7 @@ function printReadyLine(line) {
|
||||
if (!payload || payload.type !== 'orca_server_ready') {
|
||||
return false
|
||||
}
|
||||
console.log(`Orca server ready: ${payload.endpoint ?? 'websocket unavailable'}`)
|
||||
console.log(`Orca server ready: ${payload.boundEndpoint ?? 'websocket unavailable'}`)
|
||||
if (payload.pairing?.endpoint) {
|
||||
console.log(`Pairing endpoint: ${payload.pairing.endpoint}`)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.1m">
|
||||
<title>downloads: 7.1m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.2m">
|
||||
<title>downloads: 7.2m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">7.1m</text>
|
||||
<text x="93.5" y="14">7.1m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">7.2m</text>
|
||||
<text x="93.5" y="14">7.2m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -5,19 +5,39 @@ desktop session, such as an Ubuntu VPS or a remote build box.
|
||||
|
||||
`orca serve` starts the Orca runtime without opening the desktop window. On
|
||||
Linux, the packaged AppImage still needs the libraries that Electron expects at
|
||||
startup. Current Orca builds can start Xvfb automatically for `orca serve` when
|
||||
no `DISPLAY` is set, but Xvfb must be installed first. When `DISPLAY` is set,
|
||||
Orca uses that display instead of starting a competing Xvfb process.
|
||||
startup. Current Orca builds start Xvfb automatically for `orca serve` when no
|
||||
`DISPLAY` is set, but Xvfb must be installed first. A separate D-Bus session is
|
||||
not required. When `DISPLAY` is set, Orca uses that display instead of starting
|
||||
a competing Xvfb process.
|
||||
|
||||
## Ubuntu 22.04 Prerequisites
|
||||
The supported deployment matrix covers Ubuntu 22.04 and 24.04 and current
|
||||
Debian stable. Package names can differ on other Debian-derived releases.
|
||||
|
||||
## Ubuntu and Debian prerequisites
|
||||
|
||||
Install the AppImage runtime dependency and Xvfb:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl file libfuse2 xvfb
|
||||
sudo apt-get install -y curl file jq xvfb zlib1g-dev
|
||||
```
|
||||
|
||||
On Ubuntu 22.04, install `libfuse2` to execute the AppImage through FUSE. On
|
||||
Ubuntu 24.04 and Debian, the equivalent package may be `libfuse2t64`. FUSE is
|
||||
optional: without it, use the AppImage's supported extraction path:
|
||||
|
||||
```bash
|
||||
cd /opt/orca
|
||||
./orca-linux.AppImage --appimage-extract
|
||||
/opt/orca/squashfs-root/AppRun serve --port 6768
|
||||
```
|
||||
|
||||
Docker commonly has no FUSE device. Use `--appimage-extract` once or
|
||||
`--appimage-extract-and-run`; neither requires a privileged container. The
|
||||
extract-and-run wrapper can print extracted paths before Orca starts, so
|
||||
automation that requires stdout to contain only the ready JSON should extract
|
||||
once and invoke `squashfs-root/AppRun`.
|
||||
|
||||
Download and make the AppImage executable:
|
||||
|
||||
```bash
|
||||
@@ -51,7 +71,65 @@ LIBGL_ALWAYS_SOFTWARE=1 /opt/orca/orca-linux.AppImage serve \
|
||||
--pairing-address 100.64.1.20
|
||||
```
|
||||
|
||||
The command prints the runtime endpoint and pairing URL. Stop it with `Ctrl+C`.
|
||||
`--pairing-address` is only the address advertised to clients. It does not
|
||||
change the listener bind address. Orca binds its WebSocket listener, then
|
||||
combines the actual bound port with the advertised host when the address omits
|
||||
a port. Use a reachable LAN/Tailscale hostname or IP, or a complete reverse
|
||||
proxy URL such as `https://orca.example.com/runtime` (`http(s)` is normalized
|
||||
to `ws(s)`). Wildcard addresses such as `*`, `0.0.0.0`, and `::` cannot be
|
||||
advertised.
|
||||
|
||||
The command writes one ready block to stdout after the listener bind and
|
||||
pairing initialization complete:
|
||||
|
||||
```text
|
||||
Orca server ready
|
||||
Bound endpoint: ws://0.0.0.0:6768
|
||||
Advertised endpoint: ws://100.64.1.20:6768
|
||||
Pairing URL: orca://pair?code=...
|
||||
```
|
||||
|
||||
For supervisors, request the versioned single-line JSON contract:
|
||||
|
||||
```bash
|
||||
/opt/orca/orca-linux.AppImage serve --port 6768 \
|
||||
--pairing-address 100.64.1.20 --json
|
||||
```
|
||||
|
||||
The actual output is one compact line; this example is pretty-printed for
|
||||
readability:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "orca_server_ready",
|
||||
"schemaVersion": 1,
|
||||
"runtimeId": "...",
|
||||
"endpoint": "ws://0.0.0.0:6768",
|
||||
"boundEndpoint": "ws://0.0.0.0:6768",
|
||||
"advertisedEndpoint": "ws://100.64.1.20:6768",
|
||||
"managedWslCliReconciliation": "settled",
|
||||
"pairing": {
|
||||
"available": true,
|
||||
"url": "orca://pair?code=...",
|
||||
"endpoint": "ws://100.64.1.20:6768",
|
||||
"deviceId": "...",
|
||||
"webClientUrl": "...",
|
||||
"scope": "runtime",
|
||||
"qr": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`endpoint` remains a compatibility alias for `boundEndpoint`; new automation
|
||||
should use the explicit bound and advertised fields.
|
||||
|
||||
When the server remains usable but cannot mint an offer, `pairing` remains an
|
||||
object with `available:false`, a stable `reason`, and operator `guidance`; it is
|
||||
never silently omitted. `--recipe-json` is stricter and exits with that reason
|
||||
because its contract requires a pairing URL. Stop a foreground server with
|
||||
`Ctrl+C`. Stable reasons are `disabled_by_operator`, `websocket_unavailable`,
|
||||
`device_registry_unavailable`, `e2ee_key_unavailable`, and
|
||||
`invalid_advertised_endpoint`.
|
||||
|
||||
## Systemd Service
|
||||
|
||||
@@ -82,6 +160,8 @@ User=orca
|
||||
WorkingDirectory=/home/orca
|
||||
Environment=LIBGL_ALWAYS_SOFTWARE=1
|
||||
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -100,6 +180,19 @@ sudo systemctl enable --now orca-serve.service
|
||||
sudo journalctl -u orca-serve.service -f
|
||||
```
|
||||
|
||||
`journalctl -o cat` removes journal metadata but still mixes the service's
|
||||
stdout and stderr. Parse each line as JSON and require the readiness type and
|
||||
schema before treating the service as ready:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u orca-serve.service -o cat \
|
||||
| jq -Rrc 'fromjson? | select(.type == "orca_server_ready" and .schemaVersion == 1)'
|
||||
```
|
||||
|
||||
A bounded health check should require that contract within its startup timeout;
|
||||
otherwise inspect earlier diagnostics for the precise pairing reason, listener
|
||||
error, or missing library.
|
||||
|
||||
## Managed Xvfb Service
|
||||
|
||||
If you prefer to own the virtual display lifecycle in systemd, run Xvfb as a
|
||||
@@ -164,6 +257,40 @@ server. Invoke the AppImage directly:
|
||||
/opt/orca/orca-linux.AppImage serve --help
|
||||
```
|
||||
|
||||
Running an AppImage as root requires Chromium's `--no-sandbox` switch before
|
||||
the command:
|
||||
|
||||
```bash
|
||||
/opt/orca/orca-linux.AppImage --no-sandbox serve --port 6768
|
||||
```
|
||||
|
||||
This disables a security boundary. Prefer a dedicated unprivileged service
|
||||
user, especially when the listener is reachable beyond localhost.
|
||||
|
||||
## Pairing troubleshooting
|
||||
|
||||
- A pairing offer is a capability containing a device credential and E2EE
|
||||
material. Share it only with the intended client and do not put it in proxy
|
||||
access logs.
|
||||
- `boundEndpoint` is where the process listens; `advertisedEndpoint` is what a
|
||||
client dials. A valid-looking offer still cannot connect if DNS, firewall,
|
||||
Docker port publishing, Tailscale policy, or a reverse proxy does not route
|
||||
the advertised endpoint to the bound port.
|
||||
- An omitted advertised port uses the actual bound port, including a fallback
|
||||
port selected after a collision. An explicit proxy port is preserved. A port
|
||||
mismatch therefore means the supplied external routing is wrong, not that
|
||||
Orca changes it.
|
||||
- Reverse proxies must support WebSocket upgrade and route the advertised path.
|
||||
Use `wss://` or `https://` when TLS terminates at the proxy; do not advertise
|
||||
`ws://` through an HTTPS-only endpoint.
|
||||
- Hostnames, IPv4, bracketed IPv6, and raw IPv6 literals are supported. IPv6
|
||||
still requires an IPv6-reachable listener/network path.
|
||||
- `xvfb-run` and `dbus-run-session -- xvfb-run` remain valid diagnostic launch
|
||||
shapes, but neither should be needed when `Xvfb` is installed and no display
|
||||
is configured. Repeated D-Bus messages without a ready block indicate startup
|
||||
did not reach serve mode; confirm the AppImage version and exact argument
|
||||
order, especially `--no-sandbox serve`.
|
||||
|
||||
If you later install the desktop CLI from Orca settings, use that CLI for normal
|
||||
shell workflows. Keep the AppImage path in systemd so service restarts do not
|
||||
depend on an interactive shell profile.
|
||||
@@ -370,7 +497,9 @@ profile archive are complete. If you run the managed Xvfb unit, only
|
||||
sudo journalctl -u orca-serve.service -f
|
||||
```
|
||||
|
||||
A healthy start prints `Orca server ready: ws://0.0.0.0:6768` (with your port).
|
||||
A healthy start prints one `Orca server ready` block with the actual bound and
|
||||
advertised endpoints. Verify those values rather than assuming the configured
|
||||
port, because a collision can select a fallback port.
|
||||
Confirm a client reconnects before you discard the backup. The timestamped
|
||||
rollback bundles are not pruned automatically. After the new version satisfies
|
||||
your retention policy, select and inspect the newest complete bundle before
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from 'lucide-react-native'
|
||||
import type { RpcClient } from '../../../../src/transport/rpc-client'
|
||||
import { loadHosts } from '../../../../src/transport/host-store'
|
||||
import { startRuntimeCapabilityProbe } from '../../../../src/transport/runtime-capability-probe'
|
||||
import {
|
||||
loadTerminalAutocompleteEnabled,
|
||||
loadTerminalLinkOpenMode,
|
||||
@@ -250,7 +251,6 @@ import type {
|
||||
MobileSessionTabType,
|
||||
RenderableDiffLine,
|
||||
RuntimeRepoSummary,
|
||||
RuntimeStatusResult,
|
||||
SessionTabsResult,
|
||||
Terminal,
|
||||
TerminalCreateResult,
|
||||
@@ -2317,40 +2317,23 @@ export default function SessionScreen() {
|
||||
}
|
||||
// Why: a client swap can keep the route connected while moving to an older
|
||||
// host; clear the prior capability before exposing host-specific actions.
|
||||
setBrowserScreencastSupported(null)
|
||||
setAgentSessionHistorySupported(null)
|
||||
setQuickCommandsSupported(null)
|
||||
setShowQuickCommands(false)
|
||||
let stale = false
|
||||
void client
|
||||
.sendRequest('status.get')
|
||||
.then((response) => {
|
||||
if (stale || !response.ok) {
|
||||
return
|
||||
}
|
||||
const status = (response as RpcSuccess).result as RuntimeStatusResult
|
||||
setBrowserScreencastSupported(
|
||||
status.capabilities?.includes('browser.screencast.v1') === true
|
||||
)
|
||||
setAgentSessionHistorySupported(
|
||||
status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true
|
||||
)
|
||||
setQuickCommandsSupported(supportsMobileQuickCommands(status.capabilities))
|
||||
// Why: hosts without this capability strip inputKind from terminal.send,
|
||||
// so a forwarded xterm reply would become floor-stealing shell input.
|
||||
hostQueryReplyInputSupportedRef.current =
|
||||
status.capabilities?.includes(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) === true
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setBrowserScreencastSupported(false)
|
||||
setAgentSessionHistorySupported(false)
|
||||
setQuickCommandsSupported(false)
|
||||
setShowQuickCommands(false)
|
||||
hostQueryReplyInputSupportedRef.current = false
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
hostQueryReplyInputSupportedRef.current = false
|
||||
// Why: the probe retries — a relay→direct cutover or request timeout rejects
|
||||
// status.get without changing connState, which used to latch these hidden.
|
||||
return startRuntimeCapabilityProbe(client, (capabilities) => {
|
||||
setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1'))
|
||||
setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY))
|
||||
setQuickCommandsSupported(supportsMobileQuickCommands(capabilities))
|
||||
// Why: hosts without this capability strip inputKind from terminal.send,
|
||||
// so a forwarded xterm reply would become floor-stealing shell input.
|
||||
hostQueryReplyInputSupportedRef.current = capabilities.includes(
|
||||
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
|
||||
)
|
||||
})
|
||||
}, [client, connState])
|
||||
|
||||
// Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity.
|
||||
|
||||
@@ -48,6 +48,27 @@ describe('mobile session startup', () => {
|
||||
expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'")
|
||||
})
|
||||
|
||||
it('fails runtime capability gates closed before probing a replacement client', () => {
|
||||
const capabilityEffect = sliceBetween(
|
||||
'const hostQueryReplyInputSupportedRef = useRef(false)',
|
||||
'// Why: read deviceToken from host record'
|
||||
)
|
||||
const probeStart = capabilityEffect.indexOf('startRuntimeCapabilityProbe(client,')
|
||||
|
||||
expect(probeStart).toBeGreaterThanOrEqual(0)
|
||||
for (const reset of [
|
||||
'setBrowserScreencastSupported(null)',
|
||||
'setAgentSessionHistorySupported(null)',
|
||||
'setQuickCommandsSupported(null)',
|
||||
'setShowQuickCommands(false)',
|
||||
'hostQueryReplyInputSupportedRef.current = false'
|
||||
]) {
|
||||
const resetIndex = capabilityEffect.lastIndexOf(reset)
|
||||
expect(resetIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(resetIndex).toBeLessThan(probeStart)
|
||||
}
|
||||
})
|
||||
|
||||
it('activates an already-selected pending terminal tab after hydration', () => {
|
||||
expect(source).toContain(
|
||||
'const pendingTerminalActivationAttemptRef = useRef<string | null>(null)'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
|
||||
|
||||
@@ -95,10 +95,3 @@ export function useNewWorktreeRuntimeCapabilities(
|
||||
)
|
||||
return { tasksSupported, getWorktreeCreateCutoverSupport }
|
||||
}
|
||||
|
||||
function isLogicalClientCutoverError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof LogicalClientCutoverError ||
|
||||
(error instanceof Error && error.message === 'RPC interrupted by connection migration')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
|
||||
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { HostProfile } from './types'
|
||||
|
||||
export type MobileEndpointSupervisorDependencies = {
|
||||
openDirect: (endpoint: string) => RpcClient
|
||||
openRelay: (
|
||||
relay: MobileRelayEndpoint,
|
||||
credential: { token: string; version: number },
|
||||
confirmReqId: string
|
||||
) => MobileRelayRpcSession
|
||||
resolveRelay: typeof resolveMobileRelayEndpoint
|
||||
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
|
||||
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
|
||||
saveHost: (host: HostProfile) => Promise<void>
|
||||
now: () => number
|
||||
randomBytes: (length: number) => Uint8Array
|
||||
setTimer: typeof setTimeout
|
||||
clearTimer: typeof clearTimeout
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Arr
|
||||
|
||||
class FakeSession implements RpcClient {
|
||||
readonly sendRequest = vi.fn(
|
||||
async (): Promise<RpcResponse> => ({
|
||||
async (_method: string, _params?: unknown): Promise<RpcResponse> => ({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: {},
|
||||
@@ -83,6 +84,7 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
|
||||
}
|
||||
this.path = path
|
||||
this.generation += 1
|
||||
this.publishState('connected')
|
||||
})
|
||||
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
|
||||
getActivePath = () => this.path
|
||||
@@ -141,6 +143,36 @@ function dependencies(
|
||||
}
|
||||
}
|
||||
|
||||
function mockCredentialRotation(logical: FakeLogicalClient): void {
|
||||
let installResult: Record<string, unknown> | null = null
|
||||
logical.sendRequest.mockImplementation(async (method, params) => {
|
||||
const request = params as { installReqId?: string; reqId?: string }
|
||||
if (method === 'pairing.provisionRelay') {
|
||||
installResult = {
|
||||
v: 1,
|
||||
reqId: request.reqId,
|
||||
authorizationMode: 'authenticated-direct',
|
||||
currentVersion: 3,
|
||||
resumeExpiresAt: Date.now() + 300_000,
|
||||
graceExpiresAt: Date.now() + 60_000
|
||||
}
|
||||
return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
return {
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: {
|
||||
v: 1,
|
||||
relay,
|
||||
installStatus: installResult
|
||||
? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult }
|
||||
: { v: 1, reqId: request.installReqId, state: 'not-found' }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('mobile endpoint supervisor', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -172,6 +204,14 @@ describe('mobile endpoint supervisor', () => {
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
await supervisor.start()
|
||||
|
||||
logical.publishState('handshaking')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(deps.openRelay).not.toHaveBeenCalled()
|
||||
|
||||
supervisor.setForeground(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(deps.openRelay).not.toHaveBeenCalled()
|
||||
|
||||
logical.publishState('reconnecting')
|
||||
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
|
||||
|
||||
@@ -191,6 +231,27 @@ describe('mobile endpoint supervisor', () => {
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not spend a queued relay retry while direct authentication is progressing', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
|
||||
logical.publishState('handshaking')
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
|
||||
logical.publishState('disconnected')
|
||||
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('uses POST resolve for wrong-cell recovery and persists the authoritative target', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi
|
||||
@@ -228,6 +289,613 @@ describe('mobile endpoint supervisor', () => {
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('recovers the relay when it drops during an unavailable direct probe', async () => {
|
||||
const logical = new FakeLogicalClient('connected', 'relay')
|
||||
const direct = new FakeSession('connecting')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
|
||||
const deps = dependencies({
|
||||
openDirect: vi.fn(() => direct),
|
||||
openRelay
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
await supervisor.start()
|
||||
|
||||
// Start the probe, then drop the active relay while the probe owns the
|
||||
// operation mutex. The failed probe must hand recovery back to the relay.
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
expect(deps.openDirect).toHaveBeenCalledOnce()
|
||||
logical.publishState('disconnected')
|
||||
direct.publishState('disconnected')
|
||||
|
||||
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledOnce())
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('replaces a half-open relay on a network nudge, then backs off failed resumes', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('connected'))
|
||||
.mockImplementation(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
// Keep direct unavailable so relay recovery stays the only path under test.
|
||||
openDirect: vi.fn(() => new FakeSession('disconnected')),
|
||||
// Deterministic full jitter: fraction 0.5 → half the backoff window.
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
|
||||
// The OS reports a network handoff, but the dead relay never published onclose.
|
||||
supervisor.setForeground(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
// The relay cell rejects the replacement with PEER_DROPPED; more flap nudges
|
||||
// must share the existing cooldown rather than opening more sockets.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
supervisor.setForeground(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Exactly one retry fires at the 250 ms deterministic backoff boundary.
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(openRelay).toHaveBeenCalledTimes(3)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('backs off a close from the active relay before opening its replacement', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('connected', new RelayOuterError(4429)))
|
||||
.mockImplementation(() => new FakeRelaySession('connected'))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
logical.publishState('disconnected')
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('escalates backoff when relay sessions connect and then drop repeatedly', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('connected', new RelayOuterError(4408)))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
logical.publishState('disconnected')
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
logical.publishState('disconnected')
|
||||
await vi.advanceTimersByTimeAsync(499)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(openRelay).toHaveBeenCalledTimes(3)
|
||||
|
||||
logical.publishState('disconnected')
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(openRelay).toHaveBeenCalledTimes(3)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(openRelay).toHaveBeenCalledTimes(4)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not try a grace credential for a capacity failure before backing off', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4429)))
|
||||
const deps = dependencies({
|
||||
readBundle: vi.fn(async () => ({
|
||||
...bundle,
|
||||
grace: { ...bundle.current, token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1 }
|
||||
})),
|
||||
openRelay,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not redial a rejected current credential on grace cooldown retries', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(
|
||||
(_relay, credential: { version: number }) =>
|
||||
new FakeRelaySession(
|
||||
'disconnected',
|
||||
new RelayOuterError(credential.version === bundle.current.version ? 4401 : 4429)
|
||||
)
|
||||
)
|
||||
const deps = dependencies({
|
||||
readBundle: vi.fn(async () => ({
|
||||
...bundle,
|
||||
grace: { ...bundle.current, token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1 }
|
||||
})),
|
||||
openRelay,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(openRelay).toHaveBeenCalledTimes(3)
|
||||
expect(openRelay.mock.calls[2]?.[1]).toEqual(expect.objectContaining({ version: 1 }))
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('rotates a rejected current credential after grace keeps relay recovery alive', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const current = {
|
||||
...bundle.current,
|
||||
hash: hashMobileRelayCredential(bundle.current.token)
|
||||
}
|
||||
const writeBundle = vi.fn(async () => {})
|
||||
const deps = dependencies({
|
||||
readBundle: vi.fn(async () => ({
|
||||
...bundle,
|
||||
current,
|
||||
grace: { ...current, token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1 }
|
||||
})),
|
||||
openRelay: vi.fn(
|
||||
(_relay, credential: { version: number }) =>
|
||||
new FakeRelaySession(
|
||||
credential.version === current.version ? 'disconnected' : 'connected',
|
||||
credential.version === current.version ? new RelayOuterError(4401) : null
|
||||
)
|
||||
),
|
||||
writeBundle
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
writeBundle.mockClear()
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
expect(writeBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pending: expect.any(Object) })
|
||||
)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not duplicate transport failures across current and grace credentials', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new Error('network down')))
|
||||
const resolveRelay = vi.fn(async () => {
|
||||
throw new Error('director unreachable')
|
||||
})
|
||||
const deps = dependencies({
|
||||
readBundle: vi.fn(async () => ({
|
||||
...bundle,
|
||||
grace: { ...bundle.current, token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1 }
|
||||
})),
|
||||
openRelay,
|
||||
resolveRelay
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
expect(resolveRelay).toHaveBeenCalledOnce()
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('keeps an authenticated relay off the backoff path when persistence fails', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
|
||||
const deps = dependencies({
|
||||
readBundle: vi.fn(async () => ({
|
||||
...bundle,
|
||||
grace: { ...bundle.current, token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1 }
|
||||
})),
|
||||
openRelay,
|
||||
writeBundle: vi.fn(async () => {
|
||||
throw new Error('secure store unavailable')
|
||||
})
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
|
||||
logical.publishState('disconnected')
|
||||
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('cancels a pending relay retry when the original direct path reconnects', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const deps = dependencies({
|
||||
openRelay: vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))),
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
logical.publishState('connected')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('recovers a relay drop while post-migration persistence owns the mutex', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
let finishWrite: (() => void) | undefined
|
||||
const writePending = new Promise<void>((resolve) => {
|
||||
finishWrite = resolve
|
||||
})
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('connected', new RelayOuterError(4408)))
|
||||
.mockImplementation(() => new FakeRelaySession('connected'))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
writeBundle: vi.fn(() => writePending),
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
const starting = supervisor.start()
|
||||
await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce())
|
||||
logical.publishState('disconnected')
|
||||
finishWrite?.()
|
||||
await starting
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('waits for an external signal instead of polling a host-offline relay', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4404)))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
logical.publishState('disconnected')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
supervisor.setForeground(true)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('waits for direct connectivity before replacing a rejected relay credential', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4401)))
|
||||
const deps = dependencies({ openRelay })
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
supervisor.setForeground(true)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
expect(deps.writeBundle).not.toHaveBeenCalled()
|
||||
|
||||
logical.publishState('connected')
|
||||
await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce())
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('keeps rejected relay credentials gated until their replacement is durable', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4401)))
|
||||
.mockImplementation(() => new FakeRelaySession('connected'))
|
||||
let finishCredentialWrite: (() => void) | undefined
|
||||
const credentialWritePending = new Promise<void>((resolve) => {
|
||||
finishCredentialWrite = resolve
|
||||
})
|
||||
const writeBundle = vi
|
||||
.fn<(value: MobileRelayCredentialBundle) => Promise<void>>()
|
||||
.mockResolvedValue()
|
||||
.mockResolvedValueOnce()
|
||||
.mockReturnValueOnce(credentialWritePending)
|
||||
mockCredentialRotation(logical)
|
||||
const deps = dependencies({ openRelay, writeBundle })
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
logical.publishState('connected')
|
||||
await vi.waitFor(() => expect(writeBundle).toHaveBeenCalledTimes(2))
|
||||
|
||||
// The direct socket can disappear after the server commits but before the
|
||||
// replacement credential finishes its durable write.
|
||||
logical.publishState('disconnected')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
|
||||
finishCredentialWrite?.()
|
||||
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
|
||||
expect(openRelay).toHaveBeenLastCalledWith(
|
||||
relay,
|
||||
expect.objectContaining({ version: 3 }),
|
||||
expect.any(String)
|
||||
)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('uses a scheduled credential rotation that finishes after relay rejection', async () => {
|
||||
const logical = new FakeLogicalClient('connected', 'lan')
|
||||
let finishCredentialWrite: (() => void) | undefined
|
||||
const credentialWritePending = new Promise<void>((resolve) => {
|
||||
finishCredentialWrite = resolve
|
||||
})
|
||||
const writeBundle = vi
|
||||
.fn<(value: MobileRelayCredentialBundle) => Promise<void>>()
|
||||
.mockResolvedValue()
|
||||
.mockResolvedValueOnce()
|
||||
.mockReturnValueOnce(credentialWritePending)
|
||||
mockCredentialRotation(logical)
|
||||
const openRelay = vi.fn(
|
||||
(_relay, credential: { version: number }) =>
|
||||
new FakeRelaySession(
|
||||
credential.version === bundle.current.version ? 'disconnected' : 'connected',
|
||||
credential.version === bundle.current.version ? new RelayOuterError(4401) : null
|
||||
)
|
||||
)
|
||||
const deps = dependencies({
|
||||
readBundle: vi.fn(async () => ({
|
||||
...bundle,
|
||||
current: { ...bundle.current, expiresAt: Date.now() + 60_000 }
|
||||
})),
|
||||
openRelay,
|
||||
writeBundle
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
logical.publishState('connected')
|
||||
await vi.waitFor(() => expect(writeBundle).toHaveBeenCalledTimes(2))
|
||||
|
||||
// The expiring credential can be rejected while its replacement is waiting on SecureStore.
|
||||
logical.publishState('disconnected')
|
||||
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledOnce())
|
||||
finishCredentialWrite?.()
|
||||
|
||||
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
|
||||
expect(openRelay).toHaveBeenLastCalledWith(
|
||||
relay,
|
||||
expect.objectContaining({ version: 3 }),
|
||||
expect.any(String)
|
||||
)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not open a resolved relay replacement after backgrounding', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
let finishResolve: ((value: typeof relay) => void) | undefined
|
||||
const resolvePending = new Promise<typeof relay>((resolve) => {
|
||||
finishResolve = resolve
|
||||
})
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4409)))
|
||||
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408)))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
resolveRelay: vi.fn(() => resolvePending)
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
const starting = supervisor.start()
|
||||
await vi.waitFor(() => expect(deps.resolveRelay).toHaveBeenCalledOnce())
|
||||
supervisor.setForeground(false)
|
||||
finishResolve?.(relay)
|
||||
await starting
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not recreate a lease retry after forced replacement is backgrounded', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
let finishResolve: ((value: typeof relay) => void) | undefined
|
||||
const resolvePending = new Promise<typeof relay>((resolve) => {
|
||||
finishResolve = resolve
|
||||
})
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('connected', null, Date.now() + 31_000))
|
||||
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4409)))
|
||||
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408)))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
resolveRelay: vi.fn(() => resolvePending)
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await vi.waitFor(() => expect(deps.resolveRelay).toHaveBeenCalledOnce())
|
||||
supervisor.setForeground(false)
|
||||
finishResolve?.(relay)
|
||||
await vi.waitFor(() => expect(deps.saveHost).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not recreate a lease timer after stop races relay persistence', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
let finishWrite: (() => void) | undefined
|
||||
const writePending = new Promise<void>((resolve) => {
|
||||
finishWrite = resolve
|
||||
})
|
||||
const deps = dependencies({ writeBundle: vi.fn(() => writePending) })
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
const starting = supervisor.start()
|
||||
await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce())
|
||||
supervisor.stop()
|
||||
finishWrite?.()
|
||||
await starting
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not poll a host-offline relay through forced lease retries', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('connected', null, Date.now() + 31_000))
|
||||
.mockImplementation(() => new FakeRelaySession('disconnected', new RelayOuterError(4404)))
|
||||
const deps = dependencies({ openRelay })
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('keeps a fatal lease-replacement gate after the active relay later drops', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(
|
||||
new FakeRelaySession('connected', new RelayOuterError(4408), Date.now() + 31_000)
|
||||
)
|
||||
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4401)))
|
||||
.mockImplementation(() => new FakeRelaySession('connected'))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
// The old relay can outlive its rejected lease replacement, then close separately.
|
||||
logical.publishState('disconnected')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('keeps revival nudges inside a failed lease rotation cooldown', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('connected', null, Date.now() + 31_000))
|
||||
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4429)))
|
||||
.mockImplementation(() => new FakeRelaySession('connected'))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
supervisor.setForeground(true)
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(openRelay).toHaveBeenCalledTimes(3)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('keeps lease rotation inside an active relay failure cooldown', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(
|
||||
new FakeRelaySession('connected', new RelayOuterError(4429), Date.now() + 31_000)
|
||||
)
|
||||
.mockImplementation(() => new FakeRelaySession('connected'))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
await vi.advanceTimersByTimeAsync(900)
|
||||
logical.publishState('disconnected')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('clears relay backoff on a genuine foreground so the retry is immediate', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
openDirect: vi.fn(() => new FakeSession('disconnected')),
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
const afterStart = openRelay.mock.calls.length
|
||||
|
||||
// Background → foreground is a fresh signal: dial now, not after the cooldown.
|
||||
supervisor.setForeground(false)
|
||||
supervisor.setForeground(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(openRelay.mock.calls.length).toBeGreaterThan(afterStart)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('releases a background relay session and reconnects it on foreground', async () => {
|
||||
const logical = new FakeLogicalClient('connected', 'relay')
|
||||
const deps = dependencies()
|
||||
@@ -237,6 +905,7 @@ describe('mobile endpoint supervisor', () => {
|
||||
supervisor.setForeground(false)
|
||||
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
|
||||
expect(logical.getState()).toBe('disconnected')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
supervisor.setForeground(true)
|
||||
await vi.waitFor(() => expect(logical.migrateTo).toHaveBeenCalled())
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
|
||||
import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
|
||||
import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
|
||||
import { RelayReconnectController } from './mobile-relay-reconnect-controller'
|
||||
import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer'
|
||||
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
|
||||
import {
|
||||
encodeBase64Url,
|
||||
@@ -13,37 +15,17 @@ import {
|
||||
rotateMobileRelayCredential
|
||||
} from './mobile-relay-credential-rotation'
|
||||
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { HostProfile } from './types'
|
||||
|
||||
export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
|
||||
|
||||
const DIRECT_PROBE_INTERVAL_MS = 15_000
|
||||
const DIRECT_OBSERVATION_MS = 30_000
|
||||
const MINIMUM_DWELL_MS = 60_000
|
||||
const FAILURE_COOLDOWN_MS = 60_000
|
||||
const LEASE_ROTATION_MARGIN_MS = 30_000
|
||||
|
||||
export type MobileEndpointSupervisorDependencies = {
|
||||
openDirect: (endpoint: string) => RpcClient
|
||||
openRelay: (
|
||||
relay: MobileRelayEndpoint,
|
||||
credential: { token: string; version: number },
|
||||
confirmReqId: string
|
||||
) => MobileRelayRpcSession
|
||||
resolveRelay: typeof resolveMobileRelayEndpoint
|
||||
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
|
||||
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
|
||||
saveHost: (host: HostProfile) => Promise<void>
|
||||
now: () => number
|
||||
randomBytes: (length: number) => Uint8Array
|
||||
setTimer: typeof setTimeout
|
||||
clearTimer: typeof clearTimeout
|
||||
}
|
||||
|
||||
export class MobileEndpointSupervisor {
|
||||
private host: HostProfile
|
||||
private bundle: MobileRelayCredentialBundle | null = null
|
||||
private stopped = false
|
||||
private foreground = true
|
||||
@@ -51,22 +33,27 @@ export class MobileEndpointSupervisor {
|
||||
private credentialRotationInFlight = false
|
||||
private relayRotationPending = false
|
||||
private probeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private leaseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private unsubscribeState: (() => void) | null = null
|
||||
private readonly hysteresis: MobileEndpointHysteresis
|
||||
private readonly relayReconnect: RelayReconnectController
|
||||
private readonly leaseRotation: RelayLeaseRotationTimer
|
||||
|
||||
constructor(
|
||||
private readonly logical: StableLogicalRpcClient,
|
||||
host: HostProfile,
|
||||
private host: HostProfile,
|
||||
private readonly dependencies: MobileEndpointSupervisorDependencies
|
||||
) {
|
||||
this.host = host
|
||||
this.hysteresis = new MobileEndpointHysteresis(dependencies.now(), {
|
||||
directSuccessesRequired: 3,
|
||||
directObservationMs: DIRECT_OBSERVATION_MS,
|
||||
failureCooldownMs: FAILURE_COOLDOWN_MS,
|
||||
minimumDwellMs: MINIMUM_DWELL_MS
|
||||
})
|
||||
this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this))
|
||||
this.leaseRotation = new RelayLeaseRotationTimer(dependencies, () => {
|
||||
this.relayRotationPending = true
|
||||
void this.recoverRelay(true)
|
||||
})
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -77,21 +64,16 @@ export class MobileEndpointSupervisor {
|
||||
this.unsubscribeState = this.logical.onStateChange((state) => {
|
||||
if (state === 'connected') {
|
||||
if (this.logical.getActivePath() !== 'relay') {
|
||||
void this.rotateCredentialIfNeeded()
|
||||
void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
|
||||
}
|
||||
this.scheduleDirectProbe()
|
||||
} else if (state === 'reconnecting' || state === 'disconnected' || state === 'auth-failed') {
|
||||
} else {
|
||||
// Why: the direct client enters reconnecting after its first failed
|
||||
// dial and may never publish disconnected while its retry loop lives.
|
||||
void this.recoverRelay()
|
||||
this.relayReconnect.handleStateFailure(this.logical, state)
|
||||
}
|
||||
})
|
||||
const initialState = this.logical.getState()
|
||||
if (
|
||||
initialState === 'reconnecting' ||
|
||||
initialState === 'disconnected' ||
|
||||
initialState === 'auth-failed'
|
||||
) {
|
||||
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
|
||||
// Why: the first direct dial can fail while encrypted relay credentials
|
||||
// are still loading, before the supervisor subscribes to state changes.
|
||||
await this.recoverRelay()
|
||||
@@ -101,20 +83,17 @@ export class MobileEndpointSupervisor {
|
||||
}
|
||||
|
||||
setForeground(foreground: boolean): void {
|
||||
const wasForeground = this.foreground
|
||||
this.foreground = foreground
|
||||
if (foreground) {
|
||||
void this.recoverRelay(this.relayRotationPending)
|
||||
this.relayReconnect.handleForeground(this.logical, wasForeground)
|
||||
this.scheduleDirectProbe(0)
|
||||
} else {
|
||||
if (this.logical.getActivePath() === 'relay') {
|
||||
// Why: background phones must not hold billed relay data splices; the
|
||||
// stable client keeps subscriptions for authenticated foreground replay.
|
||||
this.logical.suspendActiveSession()
|
||||
}
|
||||
if (this.probeTimer) {
|
||||
this.dependencies.clearTimer(this.probeTimer)
|
||||
this.probeTimer = null
|
||||
}
|
||||
// Why: background phones must not hold billed relay data splices.
|
||||
this.relayReconnect.suspendActiveRelay(this.logical)
|
||||
this.clearDirectProbeTimer()
|
||||
this.relayReconnect.clear()
|
||||
this.leaseRotation.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,42 +101,64 @@ export class MobileEndpointSupervisor {
|
||||
this.stopped = true
|
||||
this.unsubscribeState?.()
|
||||
this.unsubscribeState = null
|
||||
if (this.probeTimer) {
|
||||
this.dependencies.clearTimer(this.probeTimer)
|
||||
this.probeTimer = null
|
||||
}
|
||||
this.clearLeaseTimer()
|
||||
this.clearDirectProbeTimer()
|
||||
this.relayReconnect.clear()
|
||||
this.leaseRotation.clear()
|
||||
}
|
||||
|
||||
private async recoverRelay(forceReplacement = false): Promise<void> {
|
||||
// Why: connecting/handshaking is live direct progress; a relay dial would race it.
|
||||
if (
|
||||
this.stopped ||
|
||||
!this.foreground ||
|
||||
this.operationInFlight ||
|
||||
!this.bundle ||
|
||||
!this.host.relay ||
|
||||
(!forceReplacement && this.logical.getState() === 'connected')
|
||||
(!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState()))
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: revival and lease timers can overlap resume failures; one shared cooldown
|
||||
// prevents PEER_DROPPED/LIMIT_EXCEEDED reconnect churn.
|
||||
if (this.relayReconnect.shouldDefer()) {
|
||||
return
|
||||
}
|
||||
this.operationInFlight = true
|
||||
let lastError: Error | null = null
|
||||
let retryAfterOperation = false
|
||||
try {
|
||||
const credentials = [this.bundle.current, this.bundle.grace].filter(
|
||||
(credential): credential is NonNullable<typeof credential> =>
|
||||
Boolean(credential && credential.expiresAt > this.dependencies.now())
|
||||
const credentials = this.relayReconnect.eligibleCredentials(
|
||||
this.bundle.current,
|
||||
this.bundle.grace
|
||||
)
|
||||
for (const credential of credentials) {
|
||||
if (await this.tryRelayCredential(credential)) {
|
||||
const result = await this.tryRelayCredential(credential)
|
||||
if (result.ok) {
|
||||
retryAfterOperation = this.logical.getState() !== 'connected'
|
||||
return
|
||||
}
|
||||
lastError = result.error
|
||||
if (this.relayReconnect.shouldTryGraceAfterRelayFailure(result.error)) {
|
||||
// Why: a rejected version stays invalid; retry only the grace credential.
|
||||
this.relayReconnect.recordRejectedCredential(credential.version)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (credentials.length > 0) {
|
||||
// Why: cleanup may happen while a relay dial is awaiting the network;
|
||||
// record its outcome without recreating a foreground retry timer.
|
||||
const scheduleRetry = !forceReplacement && this.foreground && !this.stopped
|
||||
this.relayReconnect.registerFailure(lastError, scheduleRetry)
|
||||
}
|
||||
} finally {
|
||||
this.operationInFlight = false
|
||||
if (forceReplacement && this.relayRotationPending && !this.stopped && !this.leaseTimer) {
|
||||
this.leaseTimer = this.dependencies.setTimer(() => {
|
||||
this.leaseTimer = null
|
||||
void this.recoverRelay(true)
|
||||
}, 5000)
|
||||
if (forceReplacement && this.relayRotationPending && !this.stopped && this.foreground) {
|
||||
this.leaseRotation.armRetry(this.relayReconnect.retryDelayMs(5000))
|
||||
}
|
||||
// Why: the active relay can drop while migration follow-up still owns the mutex.
|
||||
if (retryAfterOperation && !this.stopped && this.foreground) {
|
||||
void this.recoverRelay()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,13 +166,13 @@ export class MobileEndpointSupervisor {
|
||||
private async tryRelayCredential(credential: {
|
||||
token: string
|
||||
version: number
|
||||
}): Promise<boolean> {
|
||||
}): Promise<{ ok: true } | { ok: false; error: Error }> {
|
||||
const first = await this.openAndMigrateRelay(credential)
|
||||
if (first.ok) {
|
||||
return true
|
||||
return first
|
||||
}
|
||||
if (!isDirectorResolutionFailure(first.error) || !this.host.relay) {
|
||||
return false
|
||||
return first
|
||||
}
|
||||
try {
|
||||
const resolved = await this.dependencies.resolveRelay({
|
||||
@@ -179,9 +180,9 @@ export class MobileEndpointSupervisor {
|
||||
resumeToken: credential.token
|
||||
})
|
||||
this.host = await persistRelayHost(this.host, resolved, this.dependencies.saveHost)
|
||||
return (await this.openAndMigrateRelay(credential)).ok
|
||||
} catch {
|
||||
return false
|
||||
return await this.openAndMigrateRelay(credential)
|
||||
} catch (error) {
|
||||
return { ok: false, error: toError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +190,8 @@ export class MobileEndpointSupervisor {
|
||||
token: string
|
||||
version: number
|
||||
}): Promise<{ ok: true } | { ok: false; error: Error }> {
|
||||
if (!this.host.relay || !this.bundle) {
|
||||
// Why: director resolution and grace fallback can finish after background/stop.
|
||||
if (this.stopped || !this.foreground || !this.host.relay || !this.bundle) {
|
||||
return { ok: false, error: new Error('relay state missing') }
|
||||
}
|
||||
const session = this.dependencies.openRelay(
|
||||
@@ -199,17 +201,23 @@ export class MobileEndpointSupervisor {
|
||||
)
|
||||
try {
|
||||
await this.logical.migrateTo(session, 'relay')
|
||||
this.relayReconnect.setActiveSession(session)
|
||||
if (!this.foreground) {
|
||||
this.logical.suspendActiveSession()
|
||||
this.relayReconnect.suspendActiveRelay(this.logical)
|
||||
}
|
||||
this.relayRotationPending = false
|
||||
this.hysteresis.recordMigration(this.dependencies.now())
|
||||
const confirmation = session.getResumeConfirmation()
|
||||
if (confirmation) {
|
||||
this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation)
|
||||
await this.dependencies.writeBundle(this.bundle)
|
||||
// Why: the relay is already authenticated; a SecureStore failure must
|
||||
// not open another socket or count against transport recovery backoff.
|
||||
await this.dependencies.writeBundle(this.bundle).catch(() => {})
|
||||
}
|
||||
this.scheduleLeaseRotation(session)
|
||||
// Why: async persistence can finish after stop/background; never recreate a stale timer.
|
||||
this.leaseRotation.scheduleFromLease(
|
||||
this.stopped || !this.foreground ? null : session.getLeaseExpiresAt()
|
||||
)
|
||||
this.scheduleDirectProbe()
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
@@ -258,30 +266,32 @@ export class MobileEndpointSupervisor {
|
||||
await this.logical.migrateTo(successful.client, successful.path)
|
||||
successful = null
|
||||
this.hysteresis.recordMigration(this.dependencies.now())
|
||||
this.clearLeaseTimer()
|
||||
this.leaseRotation.clear()
|
||||
this.relayRotationPending = false
|
||||
await this.rotateCredentialIfNeeded()
|
||||
await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
|
||||
} finally {
|
||||
successful?.client.close()
|
||||
this.operationInFlight = false
|
||||
if (this.relayRotationPending) {
|
||||
void this.recoverRelay(true)
|
||||
// Why: a relay drop or backoff timer can arrive while the direct probe owns the mutex.
|
||||
if (this.relayRotationPending || this.logical.getState() !== 'connected') {
|
||||
void this.recoverRelay(this.relayRotationPending)
|
||||
}
|
||||
this.scheduleDirectProbe()
|
||||
}
|
||||
}
|
||||
|
||||
private async rotateCredentialIfNeeded(): Promise<void> {
|
||||
private async rotateCredentialIfNeeded(force = false): Promise<void> {
|
||||
if (
|
||||
this.stopped ||
|
||||
this.credentialRotationInFlight ||
|
||||
!this.bundle ||
|
||||
this.logical.getActivePath() === 'relay' ||
|
||||
!mobileRelayCredentialNeedsRotation(this.bundle, this.dependencies.now())
|
||||
(!force && !mobileRelayCredentialNeedsRotation(this.bundle, this.dependencies.now()))
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.credentialRotationInFlight = true
|
||||
let credentialRefreshed = false
|
||||
try {
|
||||
const result = await rotateMobileRelayCredential({
|
||||
client: this.logical,
|
||||
@@ -290,33 +300,32 @@ export class MobileEndpointSupervisor {
|
||||
randomBytes: this.dependencies.randomBytes
|
||||
})
|
||||
this.bundle = result.bundle
|
||||
// Why: a scheduled rotation can finish after the old credential enters the rejection gate.
|
||||
credentialRefreshed = true
|
||||
this.host = await persistRelayHost(this.host, result.relay, this.dependencies.saveHost)
|
||||
} catch {
|
||||
// Why: pending material remains durable; the next authenticated direct
|
||||
// opportunity must reconcile it before creating another install key.
|
||||
} finally {
|
||||
if (credentialRefreshed) {
|
||||
this.relayReconnect.completeCredentialRefresh()
|
||||
}
|
||||
this.credentialRotationInFlight = false
|
||||
if (
|
||||
credentialRefreshed &&
|
||||
!this.stopped &&
|
||||
this.foreground &&
|
||||
this.relayReconnect.needsRecovery(this.logical.getState())
|
||||
) {
|
||||
void this.recoverRelay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleLeaseRotation(session: MobileRelayRpcSession): void {
|
||||
this.clearLeaseTimer()
|
||||
const deadline = session.getLeaseExpiresAt()
|
||||
if (!deadline) {
|
||||
return
|
||||
}
|
||||
const delay = Math.max(1000, deadline - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS)
|
||||
this.leaseTimer = this.dependencies.setTimer(() => {
|
||||
this.leaseTimer = null
|
||||
this.relayRotationPending = true
|
||||
void this.recoverRelay(true)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private clearLeaseTimer(): void {
|
||||
if (this.leaseTimer) {
|
||||
this.dependencies.clearTimer(this.leaseTimer)
|
||||
this.leaseTimer = null
|
||||
private clearDirectProbeTimer(): void {
|
||||
if (this.probeTimer) {
|
||||
this.dependencies.clearTimer(this.probeTimer)
|
||||
this.probeTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Why: the relay resume lease expires; the phone must proactively re-resume a
|
||||
// little before the deadline (and retry shortly if a forced rotation didn't land)
|
||||
// so the session never lapses. Owns the single lease/rotation timer slot.
|
||||
const LEASE_ROTATION_MARGIN_MS = 30_000
|
||||
|
||||
export type RelayLeaseRotationDependencies = {
|
||||
now: () => number
|
||||
setTimer: typeof setTimeout
|
||||
clearTimer: typeof clearTimeout
|
||||
}
|
||||
|
||||
export class RelayLeaseRotationTimer {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: RelayLeaseRotationDependencies,
|
||||
private readonly onRotate: () => void
|
||||
) {}
|
||||
|
||||
// Arm rotation a margin before the lease deadline. No-op with no deadline.
|
||||
scheduleFromLease(leaseExpiresAt: number | null): void {
|
||||
this.clear()
|
||||
if (!leaseExpiresAt) {
|
||||
return
|
||||
}
|
||||
const delay = Math.max(
|
||||
1000,
|
||||
leaseExpiresAt - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS
|
||||
)
|
||||
this.arm(delay)
|
||||
}
|
||||
|
||||
// Re-arm a short retry when a forced rotation's recovery did not complete.
|
||||
// Ignored while a timer is already pending so retries never stack.
|
||||
armRetry(delayMs: number | null): void {
|
||||
if (delayMs == null || this.timer) {
|
||||
return
|
||||
}
|
||||
this.arm(delayMs)
|
||||
}
|
||||
|
||||
get pending(): boolean {
|
||||
return this.timer != null
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.timer) {
|
||||
this.dependencies.clearTimer(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
private arm(delayMs: number): void {
|
||||
this.timer = this.dependencies.setTimer(() => {
|
||||
this.timer = null
|
||||
this.onRotate()
|
||||
}, delayMs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import { RelayReconnectController } from './mobile-relay-reconnect-controller'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
|
||||
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
|
||||
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
|
||||
|
||||
describe('relay reconnect controller', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps one retry timer and cancels it when recovery needs an external signal', () => {
|
||||
const onRetry = vi.fn()
|
||||
const reconnect = createController(onRetry)
|
||||
|
||||
reconnect.registerFailure(new RelayOuterError(4429))
|
||||
reconnect.registerFailure(new RelayOuterError(4408))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
reconnect.registerFailure(new RelayOuterError(4404))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(reconnect.shouldDefer()).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
vi.runAllTimers()
|
||||
expect(onRetry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a pending relay retry after direct connectivity wins', () => {
|
||||
const onRetry = vi.fn()
|
||||
const reconnect = createController(onRetry)
|
||||
|
||||
reconnect.registerFailure(new RelayOuterError(4408))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
reconnect.resetForDirectConnection()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
vi.runAllTimers()
|
||||
expect(onRetry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for an external signal after rejected E2EE authentication', () => {
|
||||
const onRetry = vi.fn()
|
||||
const reconnect = createController(onRetry)
|
||||
|
||||
reconnect.registerFailure(new MobileE2EEAuthenticationError())
|
||||
|
||||
expect(reconnect.shouldDefer()).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(onRetry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('upgrades host-revival gating to fresh credentials without later downgrading it', () => {
|
||||
const reconnect = createController(vi.fn())
|
||||
|
||||
reconnect.registerFailure(new RelayOuterError(4404))
|
||||
reconnect.registerFailure(new RelayOuterError(4401))
|
||||
reconnect.registerFailure(new RelayOuterError(4408))
|
||||
|
||||
expect(reconnect.resetForDirectConnection()).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('extends forced-rotation retries to the exponential cooldown', () => {
|
||||
const reconnect = createController(vi.fn())
|
||||
|
||||
for (let failure = 0; failure < 6; failure++) {
|
||||
reconnect.registerFailure(new RelayOuterError(4429), false)
|
||||
}
|
||||
|
||||
expect(reconnect.retryDelayMs(5000)).toBe(8000)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not reset backoff merely because a failed attempt took one ceiling', () => {
|
||||
const onRetry = vi.fn()
|
||||
const reconnect = createController(onRetry)
|
||||
|
||||
reconnect.registerFailure(new RelayOuterError(4429))
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
|
||||
vi.advanceTimersByTime(30_000)
|
||||
reconnect.registerFailure(new RelayOuterError(4429))
|
||||
vi.advanceTimersByTime(249)
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
vi.advanceTimersByTime(249)
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(onRetry).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('resets backoff after an authenticated relay remains stable', () => {
|
||||
const onRetry = vi.fn()
|
||||
const reconnect = createController(onRetry)
|
||||
const session = {
|
||||
getFailure: () => new RelayOuterError(4408)
|
||||
} as MobileRelayRpcSession
|
||||
const logical = {
|
||||
getActivePath: () => 'relay'
|
||||
} as StableLogicalRpcClient
|
||||
|
||||
reconnect.registerFailure(new RelayOuterError(4429))
|
||||
vi.advanceTimersByTime(250)
|
||||
reconnect.setActiveSession(session)
|
||||
vi.advanceTimersByTime(30_000)
|
||||
reconnect.registerActiveFailure(logical)
|
||||
|
||||
vi.advanceTimersByTime(249)
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(onRetry).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses grace only when the outer relay credential was rejected', () => {
|
||||
const reconnect = createController(vi.fn())
|
||||
|
||||
expect(reconnect.shouldTryGraceAfterRelayFailure(new RelayOuterError(4401))).toBe(true)
|
||||
expect(reconnect.shouldTryGraceAfterRelayFailure(new Error('relay transport error'))).toBe(
|
||||
false
|
||||
)
|
||||
expect(reconnect.shouldTryGraceAfterRelayFailure(new RelayOuterError(4408))).toBe(false)
|
||||
expect(reconnect.shouldTryGraceAfterRelayFailure(new RelayOuterError(4429))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function createController(onRetry: () => void): RelayReconnectController {
|
||||
return new RelayReconnectController(
|
||||
{
|
||||
now: Date.now,
|
||||
randomBytes: () => new Uint8Array([128, 0]),
|
||||
setTimer: setTimeout,
|
||||
clearTimer: clearTimeout
|
||||
},
|
||||
onRetry
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
isMobileRelayCloseCode,
|
||||
MOBILE_RELAY_CLOSE_CODE,
|
||||
mobileRelayRecoveryFor
|
||||
} from '../../../src/shared/mobile-relay-close-codes'
|
||||
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
// Why: relay resume closes and silent cellular NAT rebinds otherwise cause
|
||||
// immediate re-dials that ping-pong the phone between connected and disconnected.
|
||||
const RELAY_BACKOFF_MIN_MS = 250
|
||||
const RELAY_BACKOFF_BASE_MS = 500
|
||||
const RELAY_BACKOFF_CEILING_MS = 30_000
|
||||
const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS
|
||||
|
||||
export type RelayReconnectDependencies = {
|
||||
now: () => number
|
||||
randomBytes: (length: number) => Uint8Array
|
||||
setTimer: typeof setTimeout
|
||||
clearTimer: typeof clearTimeout
|
||||
}
|
||||
|
||||
type RecoveryGate = 'external-signal' | 'fresh-credential'
|
||||
|
||||
export class RelayReconnectController {
|
||||
private consecutiveFailures = 0
|
||||
private activeRelayConnectedAt: number | null = null
|
||||
private nextAttemptAt = 0
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private activeSession: MobileRelayRpcSession | null = null
|
||||
private recoveryGate: RecoveryGate | null = null
|
||||
private readonly rejectedCredentialVersions = new Set<number>()
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: RelayReconnectDependencies,
|
||||
private readonly onRetry: (forceReplacement?: boolean) => void
|
||||
) {}
|
||||
|
||||
handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void {
|
||||
if (!wasForeground) {
|
||||
// Why: an app resume is a fresh signal, unlike repeated network-flap nudges.
|
||||
if (this.recoveryGate !== 'fresh-credential') {
|
||||
this.reset()
|
||||
}
|
||||
} else if (this.recoveryGate === 'external-signal') {
|
||||
this.recoveryGate = null
|
||||
}
|
||||
if (
|
||||
wasForeground &&
|
||||
this.recoveryGate !== 'fresh-credential' &&
|
||||
logical.getState() === 'connected'
|
||||
) {
|
||||
// Why: a network handoff can leave the relay half-open without publishing a close.
|
||||
this.suspendActiveRelay(logical)
|
||||
}
|
||||
// Why: revival nudges must honor failure cooldowns even when lease rotation is pending.
|
||||
this.onRetry()
|
||||
}
|
||||
|
||||
handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): void {
|
||||
if (!this.needsRecovery(state)) {
|
||||
return
|
||||
}
|
||||
this.registerActiveFailure(logical)
|
||||
this.onRetry()
|
||||
}
|
||||
|
||||
needsRecovery(state: ConnectionState): boolean {
|
||||
return state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
|
||||
}
|
||||
|
||||
suspendActiveRelay(logical: StableLogicalRpcClient): void {
|
||||
if (logical.getActivePath() !== 'relay') {
|
||||
return
|
||||
}
|
||||
this.activeSession = null
|
||||
this.activeRelayConnectedAt = null
|
||||
logical.suspendActiveSession()
|
||||
}
|
||||
|
||||
setActiveSession(session: MobileRelayRpcSession): void {
|
||||
this.activeSession = session
|
||||
this.activeRelayConnectedAt = this.dependencies.now()
|
||||
this.nextAttemptAt = 0
|
||||
this.recoveryGate = null
|
||||
this.clearTimer()
|
||||
}
|
||||
|
||||
resetForDirectConnection(): boolean {
|
||||
const needsCredentialRefresh =
|
||||
this.recoveryGate === 'fresh-credential' || this.rejectedCredentialVersions.size > 0
|
||||
this.activeSession = null
|
||||
this.activeRelayConnectedAt = null
|
||||
if (needsCredentialRefresh) {
|
||||
// Why: the rejected credential stays unusable until its replacement is durable.
|
||||
this.consecutiveFailures = 0
|
||||
this.nextAttemptAt = 0
|
||||
this.recoveryGate = 'fresh-credential'
|
||||
this.clearTimer()
|
||||
} else {
|
||||
this.reset()
|
||||
}
|
||||
return needsCredentialRefresh
|
||||
}
|
||||
|
||||
completeCredentialRefresh(): void {
|
||||
if (this.recoveryGate === 'fresh-credential') {
|
||||
this.rejectedCredentialVersions.clear()
|
||||
this.reset()
|
||||
}
|
||||
}
|
||||
|
||||
eligibleCredentials<T extends { expiresAt: number; version: number }>(
|
||||
...credentials: Array<T | null | undefined>
|
||||
): T[] {
|
||||
const eligible = credentials.filter((credential): credential is T =>
|
||||
Boolean(
|
||||
credential &&
|
||||
credential.expiresAt > this.dependencies.now() &&
|
||||
!this.rejectedCredentialVersions.has(credential.version)
|
||||
)
|
||||
)
|
||||
if (eligible.length === 0 && this.rejectedCredentialVersions.size > 0) {
|
||||
this.recoveryGate = 'fresh-credential'
|
||||
this.clearTimer()
|
||||
}
|
||||
return eligible
|
||||
}
|
||||
|
||||
recordRejectedCredential(version: number): void {
|
||||
this.rejectedCredentialVersions.add(version)
|
||||
}
|
||||
|
||||
registerActiveFailure(logical: StableLogicalRpcClient): void {
|
||||
if (logical.getActivePath() !== 'relay') {
|
||||
return
|
||||
}
|
||||
const failure = this.activeSession?.getFailure()
|
||||
this.activeSession = null
|
||||
if (failure) {
|
||||
// Why: active relay closes need the same cooldown as failed replacement dials.
|
||||
this.registerFailure(failure)
|
||||
} else {
|
||||
this.activeRelayConnectedAt = null
|
||||
}
|
||||
}
|
||||
|
||||
// True when the caller is still inside the cooldown window and must not
|
||||
// re-dial. Arms the self-scheduled retry so recovery still happens on its own.
|
||||
shouldDefer(): boolean {
|
||||
if (this.recoveryGate) {
|
||||
return true
|
||||
}
|
||||
if (this.dependencies.now() < this.nextAttemptAt) {
|
||||
this.scheduleRetry()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
registerFailure(error: Error | null, scheduleRetry = true): void {
|
||||
const code = error instanceof RelayOuterError ? error.code : null
|
||||
const recovery =
|
||||
code != null && isMobileRelayCloseCode(code)
|
||||
? mobileRelayRecoveryFor(code, 'phone-resume')
|
||||
: null
|
||||
if (
|
||||
this.recoveryGate === 'fresh-credential' ||
|
||||
(this.recoveryGate === 'external-signal' && recovery?.kind !== 'disable-relay-credential')
|
||||
) {
|
||||
// Why: only the gate's external signal can make a known-fatal recovery retryable.
|
||||
this.clearTimer()
|
||||
return
|
||||
}
|
||||
const now = this.dependencies.now()
|
||||
if (
|
||||
this.activeRelayConnectedAt != null &&
|
||||
now - this.activeRelayConnectedAt >= RELAY_STABLE_CONNECTION_MS
|
||||
) {
|
||||
this.consecutiveFailures = 0
|
||||
}
|
||||
// Why: elapsed time inside a slow failed dial is not evidence of recovery;
|
||||
// only an authenticated relay that survived the stability window resets the streak.
|
||||
this.activeRelayConnectedAt = null
|
||||
this.consecutiveFailures += 1
|
||||
const delay = this.delayMs()
|
||||
this.nextAttemptAt = now + delay
|
||||
if (error instanceof MobileE2EEAuthenticationError) {
|
||||
// Why: pairing state cannot change on a timer; polling only wakes the radio.
|
||||
this.recoveryGate = 'external-signal'
|
||||
this.clearTimer()
|
||||
return
|
||||
}
|
||||
if (recovery?.kind === 'wait-for-host-revival') {
|
||||
// Why: retrying HOST_OFFLINE without a revival signal is polling a known-negative state.
|
||||
this.recoveryGate = 'external-signal'
|
||||
this.clearTimer()
|
||||
return
|
||||
}
|
||||
if (recovery?.kind === 'disable-relay-credential') {
|
||||
// Why: a rejected outer credential cannot recover until direct connectivity refreshes it.
|
||||
this.recoveryGate = 'fresh-credential'
|
||||
this.clearTimer()
|
||||
return
|
||||
}
|
||||
this.recoveryGate = null
|
||||
if (!scheduleRetry) {
|
||||
this.clearTimer()
|
||||
return
|
||||
}
|
||||
this.scheduleRetry(delay)
|
||||
}
|
||||
|
||||
shouldTryGraceAfterRelayFailure(error: Error): boolean {
|
||||
// Why: only a rejected outer credential can be repaired by the grace token;
|
||||
// retrying session/capacity close codes immediately recreates relay churn.
|
||||
return (
|
||||
error instanceof RelayOuterError &&
|
||||
error.code === MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL
|
||||
)
|
||||
}
|
||||
|
||||
retryDelayMs(minimumMs: number): number | null {
|
||||
if (this.recoveryGate) {
|
||||
return null
|
||||
}
|
||||
return Math.max(minimumMs, this.nextAttemptAt - this.dependencies.now())
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.consecutiveFailures = 0
|
||||
this.activeRelayConnectedAt = null
|
||||
this.nextAttemptAt = 0
|
||||
this.recoveryGate = null
|
||||
this.clearTimer()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.clearTimer()
|
||||
this.activeSession = null
|
||||
this.activeRelayConnectedAt = null
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
this.dependencies.clearTimer(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRetry(delayMs?: number): void {
|
||||
if (this.timer) {
|
||||
return
|
||||
}
|
||||
const delay = delayMs ?? Math.max(0, this.nextAttemptAt - this.dependencies.now())
|
||||
this.timer = this.dependencies.setTimer(() => {
|
||||
this.timer = null
|
||||
this.onRetry()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private delayMs(): number {
|
||||
const exponent = Math.max(0, this.consecutiveFailures - 1)
|
||||
const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent)
|
||||
// Full jitter (uniform in [0, cap)), floored so retries never busy-loop.
|
||||
return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction()))
|
||||
}
|
||||
|
||||
private jitterFraction(): number {
|
||||
const [high, low] = this.dependencies.randomBytes(2)
|
||||
return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { decodePairingUrl, extractPairingCodeFromUrl, parsePairingCode } from './pairing'
|
||||
import type { PairingOffer } from './types'
|
||||
|
||||
const offer = {
|
||||
const offer: PairingOffer = {
|
||||
v: 2,
|
||||
endpoint: 'ws://100.102.47.57:6768',
|
||||
deviceToken: 'token-abc',
|
||||
publicKeyB64: 'pubkey-xyz'
|
||||
} as const
|
||||
}
|
||||
|
||||
function encodeOffer(input = offer): string {
|
||||
function encodeOffer(input: PairingOffer = offer): string {
|
||||
return btoa(JSON.stringify(input)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
@@ -61,4 +62,14 @@ describe('pairing deep links', () => {
|
||||
expect(parsePairingCode(`orca://pair?code=${code}`)).toEqual(offer)
|
||||
expect(parsePairingCode(code)).toEqual(offer)
|
||||
})
|
||||
|
||||
it('preserves a TLS reverse-proxy endpoint with an explicit port and path', () => {
|
||||
const proxiedOffer = {
|
||||
...offer,
|
||||
endpoint: 'wss://proxy.example:443/orca/runtime'
|
||||
}
|
||||
const code = encodeOffer(proxiedOffer)
|
||||
|
||||
expect(parsePairingCode(code)).toEqual(proxiedOffer)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startRuntimeCapabilityProbe } from './runtime-capability-probe'
|
||||
import { LogicalClientCutoverError } from './stable-logical-rpc-client'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcResponse } from './types'
|
||||
|
||||
type ProbeOutcome = RpcResponse | Error
|
||||
|
||||
function makeClient(outcomes: ProbeOutcome[]): { client: RpcClient; calls: () => number } {
|
||||
let calls = 0
|
||||
const client = {
|
||||
sendRequest: () => {
|
||||
const outcome = outcomes[Math.min(calls, outcomes.length - 1)]
|
||||
calls += 1
|
||||
return outcome instanceof Error ? Promise.reject(outcome) : Promise.resolve(outcome)
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
return { client, calls: () => calls }
|
||||
}
|
||||
|
||||
const ok = (capabilities: string[]): RpcResponse => ({
|
||||
ok: true,
|
||||
id: '1',
|
||||
result: { capabilities },
|
||||
_meta: { runtimeId: 'r1' }
|
||||
})
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('startRuntimeCapabilityProbe', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('delivers capabilities on first success', async () => {
|
||||
const { client, calls } = makeClient([ok(['a.v1'])])
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
await flushMicrotasks()
|
||||
expect(seen).toEqual([['a.v1']])
|
||||
expect(calls()).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('retries promptly after a logical-client cutover rejection', async () => {
|
||||
const { client, calls } = makeClient([new LogicalClientCutoverError(), ok(['a.v1'])])
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
await flushMicrotasks()
|
||||
expect(seen).toEqual([])
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(seen).toEqual([['a.v1']])
|
||||
expect(calls()).toBe(2)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('backs off on other failures and eventually recovers', async () => {
|
||||
const { client, calls } = makeClient([
|
||||
new Error('Request timed out: status.get'),
|
||||
new Error('Request timed out: status.get'),
|
||||
ok(['a.v1'])
|
||||
])
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
await flushMicrotasks()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(seen).toEqual([])
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(seen).toEqual([['a.v1']])
|
||||
expect(calls()).toBe(3)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('retries an ok:false response instead of settling', async () => {
|
||||
const failure: RpcResponse = {
|
||||
ok: false,
|
||||
id: '1',
|
||||
error: { code: 'internal', message: 'nope' },
|
||||
_meta: { runtimeId: 'r1' }
|
||||
}
|
||||
const { client } = makeClient([failure, ok(['a.v1'])])
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
await flushMicrotasks()
|
||||
expect(seen).toEqual([])
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(seen).toEqual([['a.v1']])
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('caps the failure backoff', async () => {
|
||||
const outcomes: ProbeOutcome[] = Array.from({ length: 10 }, () => new Error('timeout'))
|
||||
outcomes.push(ok(['a.v1']))
|
||||
const { client, calls } = makeClient(outcomes)
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
await flushMicrotasks()
|
||||
// Why: 1s+2s+4s+8s then 15s cap; ten failures fit well inside 8 capped waits.
|
||||
await vi.advanceTimersByTimeAsync(15_000 * 10)
|
||||
expect(seen).toEqual([['a.v1']])
|
||||
expect(calls()).toBe(11)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('stops retrying and dropping results once cancelled', async () => {
|
||||
const { client, calls } = makeClient([new Error('timeout'), ok(['a.v1'])])
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
await flushMicrotasks()
|
||||
cancel()
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(seen).toEqual([])
|
||||
expect(calls()).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores a success that resolves after cancellation', async () => {
|
||||
let resolveRequest: ((response: RpcResponse) => void) | null = null
|
||||
const client = {
|
||||
sendRequest: () =>
|
||||
new Promise<RpcResponse>((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
cancel()
|
||||
resolveRequest?.(ok(['a.v1']))
|
||||
await flushMicrotasks()
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcSuccess } from './types'
|
||||
import { isLogicalClientCutoverError } from './stable-logical-rpc-client'
|
||||
|
||||
// Why: a relay→direct cutover or request timeout can reject an in-flight
|
||||
// status.get without ever changing connState, so a one-shot probe would latch
|
||||
// capability-gated UI hidden until the screen remounts; retry until one lands.
|
||||
const CUTOVER_RETRY_DELAY_MS = 250
|
||||
const FAILURE_RETRY_BASE_DELAY_MS = 1_000
|
||||
const FAILURE_RETRY_MAX_DELAY_MS = 15_000
|
||||
|
||||
export function startRuntimeCapabilityProbe(
|
||||
client: RpcClient,
|
||||
onCapabilities: (capabilities: readonly string[]) => void
|
||||
): () => void {
|
||||
let cancelled = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let failureRetries = 0
|
||||
|
||||
function attempt(): void {
|
||||
void client.sendRequest('status.get').then(
|
||||
(response) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
scheduleRetry(false)
|
||||
return
|
||||
}
|
||||
const status = (response as RpcSuccess).result as { capabilities?: string[] }
|
||||
onCapabilities(status.capabilities ?? [])
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
scheduleRetry(isLogicalClientCutoverError(error))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function scheduleRetry(cutover: boolean): void {
|
||||
// Why: cutover means the replacement transport is already authenticated —
|
||||
// re-ask promptly; other failures back off so a wedged host isn't hammered.
|
||||
const delay = cutover
|
||||
? CUTOVER_RETRY_DELAY_MS
|
||||
: Math.min(FAILURE_RETRY_BASE_DELAY_MS * 2 ** failureRetries++, FAILURE_RETRY_MAX_DELAY_MS)
|
||||
retryTimer = setTimeout(attempt, delay)
|
||||
}
|
||||
|
||||
attempt()
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,14 @@ export class LogicalClientCutoverError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: instanceof can miss across bundle copies, so also match by message.
|
||||
export function isLogicalClientCutoverError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof LogicalClientCutoverError ||
|
||||
(error instanceof Error && error.message === 'RPC interrupted by connection migration')
|
||||
)
|
||||
}
|
||||
|
||||
type SubscriptionRecord = {
|
||||
method: string
|
||||
params: unknown
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.148-rc.1",
|
||||
"version": "1.4.150-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{
|
||||
"name": "linear-tickets",
|
||||
"sourcePath": "skills/linear-tickets",
|
||||
"releaseRevision": 6,
|
||||
"releaseRevision": 7,
|
||||
"packageDigest": "ff9f085631f753f059c631d874177ddd4fa847c5eca85a420dc85fb2bece6ff6",
|
||||
"gitTreeSha": "e35ac3c0c583661983d3fc1352ff3aec74e67e8c",
|
||||
"files": [
|
||||
@@ -94,7 +94,7 @@
|
||||
{
|
||||
"name": "orca-linear",
|
||||
"sourcePath": "skills/orca-linear",
|
||||
"releaseRevision": 4,
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "5e9622bd3883c0f53e6bd349758096deafceebd2fa260d3e90d677e64d06416d",
|
||||
"gitTreeSha": "f3727995a4719fd522119eca6d1b57542cb5fe23",
|
||||
"files": [
|
||||
|
||||
@@ -561,6 +561,19 @@
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 25
|
||||
}
|
||||
},
|
||||
{
|
||||
"appVersion": "1.4.149-rc.2",
|
||||
"skills": {
|
||||
"computer-use": 5,
|
||||
"linear-tickets": 6,
|
||||
"orca-cli": 35,
|
||||
"orca-emulator": 4,
|
||||
"orca-emulator-android": 2,
|
||||
"orca-linear": 4,
|
||||
"orca-per-workspace-env": 2,
|
||||
"orchestration": 25
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1214,6 +1214,22 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 6,
|
||||
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
|
||||
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 10596,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
|
||||
"textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
|
||||
"identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 7,
|
||||
"packageDigest": "ff9f085631f753f059c631d874177ddd4fa847c5eca85a420dc85fb2bece6ff6",
|
||||
"gitTreeSha": "e35ac3c0c583661983d3fc1352ff3aec74e67e8c",
|
||||
"files": [
|
||||
@@ -1280,6 +1296,22 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
|
||||
"gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 10320,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
|
||||
"textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
|
||||
"identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "5e9622bd3883c0f53e6bd349758096deafceebd2fa260d3e90d677e64d06416d",
|
||||
"gitTreeSha": "f3727995a4719fd522119eca6d1b57542cb5fe23",
|
||||
"files": [
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { resolve } from 'node:path'
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
|
||||
import {
|
||||
getServeUpdateHandoffPath,
|
||||
parseServeUpdateHandoffState
|
||||
} from '../../shared/serve-update-handoff'
|
||||
import {
|
||||
readServeUpdateHandoff,
|
||||
SERVE_REPLACEMENT_READY_TIMEOUT_MS
|
||||
} from './serve-update-supervisor'
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn()
|
||||
@@ -17,6 +27,7 @@ class FakeChildProcess extends EventEmitter {
|
||||
stdout = new EventEmitter()
|
||||
kill = vi.fn()
|
||||
unref = vi.fn()
|
||||
pid = 4101
|
||||
}
|
||||
|
||||
const RECIPE_JSON = JSON.stringify({
|
||||
@@ -71,6 +82,8 @@ function startRecipeJsonServer() {
|
||||
}
|
||||
|
||||
describe('serveOrcaApp', () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset()
|
||||
process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca'
|
||||
@@ -80,8 +93,239 @@ describe('serveOrcaApp', () => {
|
||||
vi.restoreAllMocks()
|
||||
delete process.env.ORCA_APP_EXECUTABLE
|
||||
delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT
|
||||
delete process.env.ORCA_APPIMAGE_NO_SANDBOX
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
return Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'keeps the serve supervisor alive until the installed target version can take ownership',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-serve-update-'))
|
||||
temporaryDirectories.push(root)
|
||||
const appPath = join(root, 'Orca.app')
|
||||
const executable = join(appPath, 'Contents', 'MacOS', 'Orca')
|
||||
const infoPlistPath = join(appPath, 'Contents', 'Info.plist')
|
||||
const userDataPath = join(root, 'user-data')
|
||||
await mkdir(join(appPath, 'Contents', 'MacOS'), { recursive: true })
|
||||
await mkdir(userDataPath, { recursive: true })
|
||||
await writeFile(
|
||||
infoPlistPath,
|
||||
'<plist><dict><key>CFBundleShortVersionString</key><string>1.0.51</string></dict></plist>'
|
||||
)
|
||||
process.env.ORCA_APP_EXECUTABLE = executable
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
|
||||
const oldOwner = new FakeChildProcess()
|
||||
const replacementOwner = new FakeChildProcess()
|
||||
replacementOwner.pid = 4102
|
||||
spawnMock.mockReturnValueOnce(oldOwner).mockReturnValueOnce(replacementOwner)
|
||||
let supervisorExited = false
|
||||
const supervisor = serveOrcaApp({ json: true }).then((code) => {
|
||||
supervisorExited = true
|
||||
return code
|
||||
})
|
||||
const childEnv = spawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv | undefined
|
||||
const handoffPath = childEnv?.ORCA_SERVE_UPDATE_HANDOFF_PATH
|
||||
expect(handoffPath).toBeTruthy()
|
||||
await writeFile(
|
||||
handoffPath!,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: oldOwner.pid
|
||||
})
|
||||
)
|
||||
|
||||
oldOwner.emit('exit', 0, null)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(supervisorExited).toBe(false)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const updateAppPath = join(root, 'Update.app')
|
||||
await mkdir(join(updateAppPath, 'Contents'), { recursive: true })
|
||||
await writeFile(
|
||||
join(updateAppPath, 'Contents', 'Info.plist'),
|
||||
'<plist><dict><key>CFBundleShortVersionString</key><string>1.0.61</string></dict></plist>'
|
||||
)
|
||||
await rename(appPath, join(root, 'Previous.app'))
|
||||
await rename(updateAppPath, appPath)
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
|
||||
replacementOwner.emit('message', {
|
||||
type: 'orca:serve-ready',
|
||||
version: '1.0.61',
|
||||
runtimeId: 'runtime-new'
|
||||
})
|
||||
|
||||
expect(supervisorExited).toBe(false)
|
||||
await vi.waitFor(async () => expect(await readServeUpdateHandoff(handoffPath!)).toBeNull())
|
||||
replacementOwner.emit('exit', 0, null)
|
||||
await expect(supervisor).resolves.toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'records a replacement version mismatch without starting a retry loop',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-serve-update-mismatch-'))
|
||||
temporaryDirectories.push(root)
|
||||
const appPath = join(root, 'Orca.app')
|
||||
const executable = join(appPath, 'Contents', 'MacOS', 'Orca')
|
||||
const userDataPath = join(root, 'user-data')
|
||||
await mkdir(join(appPath, 'Contents', 'MacOS'), { recursive: true })
|
||||
await mkdir(userDataPath, { recursive: true })
|
||||
await writeFile(
|
||||
join(appPath, 'Contents', 'Info.plist'),
|
||||
'<plist><dict><key>CFBundleShortVersionString</key><string>1.0.61</string></dict></plist>'
|
||||
)
|
||||
const handoffPath = getServeUpdateHandoffPath(userDataPath)
|
||||
await writeFile(
|
||||
handoffPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: 4101
|
||||
})
|
||||
)
|
||||
process.env.ORCA_APP_EXECUTABLE = executable
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
const replacementOwner = new FakeChildProcess()
|
||||
replacementOwner.pid = 4102
|
||||
spawnMock.mockReturnValue(replacementOwner)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const supervisor = serveOrcaApp({ json: true })
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
replacementOwner.emit('message', {
|
||||
type: 'orca:serve-ready',
|
||||
version: '1.0.51',
|
||||
runtimeId: 'runtime-old'
|
||||
})
|
||||
await vi.waitFor(() => expect(replacementOwner.kill).toHaveBeenCalledWith('SIGTERM'))
|
||||
replacementOwner.emit('exit', 0, null)
|
||||
|
||||
await expect(supervisor).resolves.toBe(1)
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
expect(parseServeUpdateHandoffState(JSON.parse(await readFile(handoffPath, 'utf8')))).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'failed',
|
||||
targetVersion: '1.0.61',
|
||||
reason: expect.stringContaining('reported version 1.0.51')
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'records replacement spawn failure before rejecting without a retry loop',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-serve-update-spawn-failure-'))
|
||||
temporaryDirectories.push(root)
|
||||
const appPath = join(root, 'Orca.app')
|
||||
const executable = join(appPath, 'Contents', 'MacOS', 'Orca')
|
||||
const userDataPath = join(root, 'user-data')
|
||||
await mkdir(join(appPath, 'Contents', 'MacOS'), { recursive: true })
|
||||
await mkdir(userDataPath, { recursive: true })
|
||||
await writeFile(
|
||||
join(appPath, 'Contents', 'Info.plist'),
|
||||
'<plist><dict><key>CFBundleShortVersionString</key><string>1.0.61</string></dict></plist>'
|
||||
)
|
||||
const handoffPath = getServeUpdateHandoffPath(userDataPath)
|
||||
await writeFile(
|
||||
handoffPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: 4101
|
||||
})
|
||||
)
|
||||
process.env.ORCA_APP_EXECUTABLE = executable
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
const replacementOwner = new FakeChildProcess()
|
||||
spawnMock.mockReturnValue(replacementOwner)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const supervisor = serveOrcaApp({ json: true })
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
replacementOwner.emit('error', new Error('spawn ENOENT'))
|
||||
|
||||
await expect(supervisor).rejects.toThrow('spawn ENOENT')
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
expect(parseServeUpdateHandoffState(JSON.parse(await readFile(handoffPath, 'utf8')))).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'failed',
|
||||
targetVersion: '1.0.61',
|
||||
reason: expect.stringContaining('spawn ENOENT')
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'fails a replacement that never reports runtime readiness without retrying it',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-serve-update-no-readiness-'))
|
||||
temporaryDirectories.push(root)
|
||||
const appPath = join(root, 'Orca.app')
|
||||
const executable = join(appPath, 'Contents', 'MacOS', 'Orca')
|
||||
const userDataPath = join(root, 'user-data')
|
||||
await mkdir(join(appPath, 'Contents', 'MacOS'), { recursive: true })
|
||||
await mkdir(userDataPath, { recursive: true })
|
||||
await writeFile(
|
||||
join(appPath, 'Contents', 'Info.plist'),
|
||||
'<plist><dict><key>CFBundleShortVersionString</key><string>1.0.61</string></dict></plist>'
|
||||
)
|
||||
const handoffPath = getServeUpdateHandoffPath(userDataPath)
|
||||
await writeFile(
|
||||
handoffPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: 4101
|
||||
})
|
||||
)
|
||||
process.env.ORCA_APP_EXECUTABLE = executable
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
const replacementOwner = new FakeChildProcess()
|
||||
spawnMock.mockReturnValue(replacementOwner)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
try {
|
||||
const supervisor = serveOrcaApp({ json: true })
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SERVE_REPLACEMENT_READY_TIMEOUT_MS)
|
||||
expect(replacementOwner.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
replacementOwner.emit('exit', 0, null)
|
||||
|
||||
await expect(supervisor).resolves.toBe(1)
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
parseServeUpdateHandoffState(JSON.parse(await readFile(handoffPath, 'utf8')))
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'failed',
|
||||
reason: expect.stringContaining('did not report serving version')
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('pins the Electron child cwd to the app root instead of the caller cwd', async () => {
|
||||
const child = {
|
||||
kill: vi.fn(),
|
||||
@@ -147,6 +391,32 @@ describe('serveOrcaApp', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves an AppImage no-sandbox launch for the server child', async () => {
|
||||
process.env.ORCA_APPIMAGE_NO_SANDBOX = '1'
|
||||
const child = {
|
||||
kill: vi.fn(),
|
||||
once: vi.fn(
|
||||
(event: string, handler: (code: number | null, signal: string | null) => void) => {
|
||||
if (event === 'exit') {
|
||||
queueMicrotask(() => handler(0, null))
|
||||
}
|
||||
return child
|
||||
}
|
||||
)
|
||||
}
|
||||
spawnMock.mockReturnValue(child)
|
||||
|
||||
await expect(serveOrcaApp({ json: true })).resolves.toBe(0)
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'/Applications/Orca.app/Contents/MacOS/Orca',
|
||||
['--no-sandbox', '--serve', '--serve-json'],
|
||||
expect.any(Object)
|
||||
)
|
||||
const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv }
|
||||
expect(spawnOptions.env).not.toHaveProperty('ORCA_APPIMAGE_NO_SANDBOX')
|
||||
})
|
||||
|
||||
it('passes the app root before serve flags for dev Electron executables', async () => {
|
||||
process.env.ORCA_APP_EXECUTABLE = '/repo/node_modules/.bin/electron'
|
||||
process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1'
|
||||
|
||||
+56
-47
@@ -1,10 +1,21 @@
|
||||
import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { resolve } from 'node:path'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import {
|
||||
SERVE_UPDATE_HANDOFF_PATH_ENV,
|
||||
getServeUpdateHandoffPath
|
||||
} from '../../shared/serve-update-handoff'
|
||||
import {
|
||||
getEphemeralVmRecipeResultConnection,
|
||||
parseEphemeralVmRecipeResult
|
||||
} from '../../shared/ephemeral-vm-recipes'
|
||||
import { getDefaultUserDataPath } from './metadata'
|
||||
import { getMacAppBundlePath } from './mac-app-update-bundle'
|
||||
import {
|
||||
readServeUpdateHandoffSync,
|
||||
resumeInterruptedServeUpdate,
|
||||
superviseForegroundServe
|
||||
} from './serve-update-supervisor'
|
||||
import { RuntimeClientError } from './types'
|
||||
|
||||
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
|
||||
@@ -75,7 +86,11 @@ export function serveOrcaApp(
|
||||
} = {}
|
||||
): Promise<number> {
|
||||
const executable = resolveForegroundOrcaExecutable()
|
||||
const childArgs = [...getExecutableAppArgs(), '--serve']
|
||||
const childArgs = [...getExecutableAppArgs()]
|
||||
if (process.env.ORCA_APPIMAGE_NO_SANDBOX === '1') {
|
||||
childArgs.push('--no-sandbox')
|
||||
}
|
||||
childArgs.push('--serve')
|
||||
if (args.json) {
|
||||
childArgs.push('--serve-json')
|
||||
}
|
||||
@@ -101,48 +116,52 @@ export function serveOrcaApp(
|
||||
childArgs.push('--serve-recipe-json', '--serve-project-root', args.projectRoot)
|
||||
}
|
||||
|
||||
const child = spawnProcess(executable, childArgs, {
|
||||
const handoffPath =
|
||||
args.recipeJson !== true && getMacAppBundlePath(executable)
|
||||
? getServeUpdateHandoffPath(getDefaultUserDataPath())
|
||||
: null
|
||||
const childEnv = stripElectronRunAsNode(process.env)
|
||||
delete childEnv.ORCA_APPIMAGE_NO_SANDBOX
|
||||
if (handoffPath) {
|
||||
childEnv[SERVE_UPDATE_HANDOFF_PATH_ENV] = handoffPath
|
||||
}
|
||||
const spawnOptions: SpawnOptions = {
|
||||
detached: args.recipeJson === true,
|
||||
cwd: resolveAppRoot(),
|
||||
stdio: args.recipeJson === true ? ['ignore', 'pipe', 'inherit'] : 'inherit',
|
||||
stdio:
|
||||
args.recipeJson === true
|
||||
? ['ignore', 'pipe', 'inherit']
|
||||
: handoffPath
|
||||
? ['inherit', 'inherit', 'inherit', 'ipc']
|
||||
: 'inherit',
|
||||
...getExecutableSpawnOptions(executable),
|
||||
env: stripElectronRunAsNode(process.env)
|
||||
})
|
||||
env: childEnv
|
||||
}
|
||||
const interruptedHandoff = handoffPath ? readServeUpdateHandoffSync(handoffPath) : null
|
||||
if (interruptedHandoff?.phase === 'install-requested') {
|
||||
// Why: the node-mode CLI is not an NSRunningApplication, so it can retain launchd ownership while ShipIt swaps the app.
|
||||
return resumeInterruptedServeUpdate({
|
||||
executable,
|
||||
childArgs,
|
||||
spawnOptions,
|
||||
spawnChild: spawnProcess,
|
||||
handoffPath: handoffPath!,
|
||||
handoff: interruptedHandoff
|
||||
})
|
||||
}
|
||||
const child = spawnProcess(executable, childArgs, spawnOptions)
|
||||
|
||||
if (args.recipeJson) {
|
||||
return waitForRecipeJson(child)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const forwardSignal = (signal: NodeJS.Signals): void => {
|
||||
child.kill(signal)
|
||||
forceKillTimer ??= setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
}, 5000)
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
process.off('SIGINT', forwardSignal)
|
||||
process.off('SIGTERM', forwardSignal)
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer)
|
||||
forceKillTimer = null
|
||||
}
|
||||
}
|
||||
process.on('SIGINT', forwardSignal)
|
||||
process.on('SIGTERM', forwardSignal)
|
||||
child.once('error', (error) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
cleanup()
|
||||
if (typeof code === 'number') {
|
||||
resolve(code)
|
||||
return
|
||||
}
|
||||
reject(new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${signal}`))
|
||||
})
|
||||
return superviseForegroundServe({
|
||||
executable,
|
||||
childArgs,
|
||||
spawnOptions,
|
||||
spawnChild: spawnProcess,
|
||||
child,
|
||||
handoffPath,
|
||||
expectedHandoff: null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -271,13 +290,3 @@ export function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEn
|
||||
delete next.ELECTRON_RUN_AS_NODE
|
||||
return next
|
||||
}
|
||||
|
||||
function getMacAppBundlePath(execPath: string): string | null {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
const macOsDir = dirname(execPath)
|
||||
const contentsDir = dirname(macOsDir)
|
||||
const appBundlePath = dirname(contentsDir)
|
||||
return appBundlePath.endsWith('.app') ? appBundlePath : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { watch, type FSWatcher } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const MAC_BUNDLE_UPDATE_TIMEOUT_MS = 120_000
|
||||
|
||||
export function getMacAppBundlePath(executable: string): string | null {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
const macOsDir = dirname(executable)
|
||||
const contentsDir = dirname(macOsDir)
|
||||
const appBundlePath = dirname(contentsDir)
|
||||
return appBundlePath.endsWith('.app') ? appBundlePath : null
|
||||
}
|
||||
|
||||
export async function waitForMacBundleVersion(
|
||||
executable: string,
|
||||
targetVersion: string,
|
||||
timeoutMs = MAC_BUNDLE_UPDATE_TIMEOUT_MS
|
||||
): Promise<boolean> {
|
||||
const appBundlePath = getMacAppBundlePath(executable)
|
||||
if (!appBundlePath) {
|
||||
return false
|
||||
}
|
||||
const infoPlistPath = resolve(appBundlePath, 'Contents', 'Info.plist')
|
||||
if ((await readMacBundleVersion(infoPlistPath)) === targetVersion) {
|
||||
return true
|
||||
}
|
||||
|
||||
return new Promise((resolveWait) => {
|
||||
let settled = false
|
||||
let checking = false
|
||||
let watcher: FSWatcher | null = null
|
||||
let poll: ReturnType<typeof setInterval> | null = null
|
||||
const finish = (ready: boolean): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
if (poll) {
|
||||
clearInterval(poll)
|
||||
}
|
||||
watcher?.close()
|
||||
resolveWait(ready)
|
||||
}
|
||||
const check = (): void => {
|
||||
if (checking || settled) {
|
||||
return
|
||||
}
|
||||
checking = true
|
||||
void readMacBundleVersion(infoPlistPath)
|
||||
.then((version) => {
|
||||
if (version === targetVersion) {
|
||||
finish(true)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checking = false
|
||||
})
|
||||
}
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs)
|
||||
poll = setInterval(check, 250)
|
||||
try {
|
||||
// Why: ShipIt replaces the whole .app, so watch its stable parent rather than an inode inside the old bundle.
|
||||
watcher = watch(dirname(appBundlePath), check)
|
||||
watcher.on('error', () => {
|
||||
watcher?.close()
|
||||
watcher = null
|
||||
})
|
||||
} catch {
|
||||
watcher = null
|
||||
}
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
async function readMacBundleVersion(infoPlistPath: string): Promise<string | null> {
|
||||
try {
|
||||
const plist = await readFile(infoPlistPath, 'utf8')
|
||||
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(plist)
|
||||
return match?.[1]?.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { ChildProcess, SpawnOptions, spawn } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import {
|
||||
parseServeSupervisorMessage,
|
||||
parseServeUpdateHandoffState,
|
||||
type ServeUpdateHandoffState
|
||||
} from '../../shared/serve-update-handoff'
|
||||
import { RuntimeClientError } from './types'
|
||||
import { waitForMacBundleVersion } from './mac-app-update-bundle'
|
||||
|
||||
export const SERVE_REPLACEMENT_READY_TIMEOUT_MS = 60_000
|
||||
|
||||
type InstallRequestedHandoff = Extract<ServeUpdateHandoffState, { phase: 'install-requested' }>
|
||||
type ServeReadiness = 'not-expected' | 'pending' | 'verified' | 'failed'
|
||||
|
||||
type ServeSupervisorArgs = {
|
||||
executable: string
|
||||
childArgs: string[]
|
||||
spawnOptions: SpawnOptions
|
||||
spawnChild: typeof spawn
|
||||
handoffPath: string | null
|
||||
}
|
||||
|
||||
export async function resumeInterruptedServeUpdate(
|
||||
args: ServeSupervisorArgs & { handoffPath: string; handoff: InstallRequestedHandoff }
|
||||
): Promise<number> {
|
||||
const installed = await waitForMacBundleVersion(args.executable, args.handoff.targetVersion)
|
||||
if (!installed) {
|
||||
await recordServeUpdateHandoffFailure(
|
||||
args.handoffPath,
|
||||
args.handoff,
|
||||
`Timed out waiting for Orca ${args.handoff.targetVersion} to be installed.`
|
||||
)
|
||||
}
|
||||
const child = args.spawnChild(args.executable, args.childArgs, args.spawnOptions)
|
||||
return superviseForegroundServe({
|
||||
...args,
|
||||
child,
|
||||
expectedHandoff: installed ? args.handoff : null
|
||||
})
|
||||
}
|
||||
|
||||
export async function superviseForegroundServe(
|
||||
args: ServeSupervisorArgs & {
|
||||
child: ChildProcess
|
||||
expectedHandoff: InstallRequestedHandoff | null
|
||||
}
|
||||
): Promise<number> {
|
||||
let child = args.child
|
||||
let expectedHandoff = args.expectedHandoff
|
||||
|
||||
while (true) {
|
||||
const result = await waitForForegroundChild(
|
||||
child,
|
||||
args.handoffPath && expectedHandoff
|
||||
? { handoffPath: args.handoffPath, handoff: expectedHandoff }
|
||||
: null
|
||||
)
|
||||
|
||||
if (result.readiness === 'failed') {
|
||||
return 1
|
||||
}
|
||||
if (expectedHandoff && result.readiness !== 'verified') {
|
||||
if (args.handoffPath) {
|
||||
await recordServeUpdateHandoffFailure(
|
||||
args.handoffPath,
|
||||
expectedHandoff,
|
||||
`Replacement exited before serving version ${expectedHandoff.targetVersion}.`
|
||||
)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const handoff = args.handoffPath ? await readServeUpdateHandoff(args.handoffPath) : null
|
||||
if (
|
||||
handoff?.phase !== 'install-requested' ||
|
||||
(child.pid !== undefined && handoff.servingPid !== child.pid)
|
||||
) {
|
||||
if (typeof result.code === 'number') {
|
||||
return result.code
|
||||
}
|
||||
throw new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${result.signal}`)
|
||||
}
|
||||
|
||||
const installed = await waitForMacBundleVersion(args.executable, handoff.targetVersion)
|
||||
if (!installed) {
|
||||
await recordServeUpdateHandoffFailure(
|
||||
args.handoffPath!,
|
||||
handoff,
|
||||
`Timed out waiting for Orca ${handoff.targetVersion} to be installed.`
|
||||
)
|
||||
expectedHandoff = null
|
||||
} else {
|
||||
expectedHandoff = handoff
|
||||
}
|
||||
child = args.spawnChild(args.executable, args.childArgs, args.spawnOptions)
|
||||
}
|
||||
}
|
||||
|
||||
function waitForForegroundChild(
|
||||
child: ChildProcess,
|
||||
expected: { handoffPath: string; handoff: InstallRequestedHandoff } | null
|
||||
): Promise<{
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
readiness: ServeReadiness
|
||||
}> {
|
||||
return new Promise((resolveWait, reject) => {
|
||||
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let readyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let readiness: ServeReadiness = expected ? 'pending' : 'not-expected'
|
||||
let stateWrite = Promise.resolve()
|
||||
const terminateChild = (): void => {
|
||||
child.kill('SIGTERM')
|
||||
forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), 5000)
|
||||
}
|
||||
const recordReplacementFailure = (reason: string): boolean => {
|
||||
if (!expected || readiness !== 'pending') {
|
||||
return false
|
||||
}
|
||||
readiness = 'failed'
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer)
|
||||
readyTimer = null
|
||||
}
|
||||
stateWrite = recordServeUpdateHandoffFailure(
|
||||
expected.handoffPath,
|
||||
expected.handoff,
|
||||
reason
|
||||
).catch((error) => {
|
||||
process.stderr.write(`[serve] could not record update handoff failure: ${String(error)}\n`)
|
||||
})
|
||||
return true
|
||||
}
|
||||
const rejectReplacement = (reason: string): void => {
|
||||
if (!recordReplacementFailure(reason)) {
|
||||
return
|
||||
}
|
||||
terminateChild()
|
||||
}
|
||||
const forwardSignal = (signal: NodeJS.Signals): void => {
|
||||
child.kill(signal)
|
||||
forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), 5000)
|
||||
}
|
||||
const handleMessage = (value: unknown): void => {
|
||||
const message = parseServeSupervisorMessage(value)
|
||||
if (!message || !expected || readiness !== 'pending') {
|
||||
return
|
||||
}
|
||||
if (message.version !== expected.handoff.targetVersion) {
|
||||
rejectReplacement(
|
||||
`Replacement reported version ${message.version}; expected ${expected.handoff.targetVersion}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
readiness = 'verified'
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer)
|
||||
readyTimer = null
|
||||
}
|
||||
stateWrite = completeServeUpdateHandoff(
|
||||
expected.handoffPath,
|
||||
expected.handoff,
|
||||
message.runtimeId
|
||||
).catch((error) => {
|
||||
readiness = 'failed'
|
||||
process.stderr.write(`[serve] could not complete update handoff: ${String(error)}\n`)
|
||||
terminateChild()
|
||||
})
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
process.off('SIGINT', forwardSignal)
|
||||
process.off('SIGTERM', forwardSignal)
|
||||
if (typeof child.off === 'function') {
|
||||
child.off('message', handleMessage)
|
||||
}
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer)
|
||||
}
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer)
|
||||
}
|
||||
}
|
||||
process.on('SIGINT', forwardSignal)
|
||||
process.on('SIGTERM', forwardSignal)
|
||||
if (typeof child.on === 'function') {
|
||||
child.on('message', handleMessage)
|
||||
}
|
||||
if (expected) {
|
||||
readyTimer = setTimeout(() => {
|
||||
rejectReplacement(
|
||||
`Replacement did not report serving version ${expected.handoff.targetVersion} within ${SERVE_REPLACEMENT_READY_TIMEOUT_MS}ms.`
|
||||
)
|
||||
}, SERVE_REPLACEMENT_READY_TIMEOUT_MS)
|
||||
}
|
||||
const handleExit = (code: number | null, signal: NodeJS.Signals | null): void => {
|
||||
cleanup()
|
||||
void stateWrite.then(() => resolveWait({ code, signal, readiness }))
|
||||
}
|
||||
child.once('error', (error) => {
|
||||
recordReplacementFailure(`Could not start the replacement process: ${String(error)}`)
|
||||
cleanup()
|
||||
child.off('exit', handleExit)
|
||||
// Why: the LaunchAgent may restart this parent immediately, so durable failure must precede process rejection.
|
||||
void stateWrite.then(() => reject(error))
|
||||
})
|
||||
child.once('exit', handleExit)
|
||||
})
|
||||
}
|
||||
|
||||
export async function readServeUpdateHandoff(
|
||||
handoffPath: string
|
||||
): Promise<ServeUpdateHandoffState | null> {
|
||||
try {
|
||||
return parseServeUpdateHandoffState(JSON.parse(await readFile(handoffPath, 'utf8')))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readServeUpdateHandoffSync(handoffPath: string): ServeUpdateHandoffState | null {
|
||||
try {
|
||||
return parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8')))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearServeUpdateHandoff(handoffPath: string): Promise<void> {
|
||||
await unlink(handoffPath).catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function completeServeUpdateHandoff(
|
||||
handoffPath: string,
|
||||
state: InstallRequestedHandoff,
|
||||
runtimeId: string
|
||||
): Promise<void> {
|
||||
await writeServeUpdateHandoffState(handoffPath, {
|
||||
...state,
|
||||
phase: 'completed',
|
||||
runtimeId
|
||||
})
|
||||
await clearServeUpdateHandoff(handoffPath)
|
||||
}
|
||||
|
||||
export async function recordServeUpdateHandoffFailure(
|
||||
handoffPath: string,
|
||||
state: Extract<ServeUpdateHandoffState, { phase: 'install-requested' }>,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
const failedState: ServeUpdateHandoffState = { ...state, phase: 'failed', reason }
|
||||
await writeServeUpdateHandoffState(handoffPath, failedState)
|
||||
process.stderr.write(`[serve] update handoff failed: ${reason}\n`)
|
||||
}
|
||||
|
||||
async function writeServeUpdateHandoffState(
|
||||
handoffPath: string,
|
||||
state: ServeUpdateHandoffState
|
||||
): Promise<void> {
|
||||
const temporaryPath = `${handoffPath}.${process.pid}.tmp`
|
||||
await writeFile(temporaryPath, JSON.stringify(state), { mode: 0o600 })
|
||||
await rename(temporaryPath, handoffPath)
|
||||
}
|
||||
@@ -17,8 +17,8 @@ export const SERVE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'recipe-json'
|
||||
],
|
||||
notes: [
|
||||
'Runs in the foreground and prints the runtime endpoint. Stop it with Ctrl+C.',
|
||||
'Use --pairing-address when clients should connect through a LAN, Tailscale, SSH-forward, or public tunnel address.',
|
||||
'Runs in the foreground and prints the bound endpoint, advertised endpoint, and pairing status. Stop it with Ctrl+C.',
|
||||
'--pairing-address changes only the client-advertised address; use a reachable LAN, Tailscale, SSH-forward, or reverse-proxy endpoint.',
|
||||
'Use --recipe-json with --project-root from VM recipes to print the recipe result JSON and leave the server running.',
|
||||
'Use --mobile-pairing to print a mobile-scoped pairing QR/link instead of the default runtime-environment pairing link.',
|
||||
'When the web client bundle is available, the server also prints a browser URL with the pairing data embedded.'
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
parseAgentStatusPayload
|
||||
} from '../../shared/agent-status-types'
|
||||
import {
|
||||
createHookListenerState,
|
||||
normalizeHookPayload,
|
||||
type HookListenerState
|
||||
} from '../../shared/agent-hook-listener'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
@@ -89,7 +94,144 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('applies inferred interrupts through the cached status lifecycle', () => {
|
||||
it('preserves Codex sibling and lead state across relay listener restarts', () => {
|
||||
const server = new AgentHookServer()
|
||||
const send = (state: HookListenerState, payload: Record<string, unknown>): void => {
|
||||
const event = normalizeHookPayload(state, 'codex', buildBody(payload), 'production')
|
||||
if (!event) {
|
||||
throw new Error('normalizeHookPayload rejected a known-good Codex fixture')
|
||||
}
|
||||
server.ingestRemote(event, 'conn-1')
|
||||
}
|
||||
|
||||
const initialRelay = createHookListenerState()
|
||||
send(initialRelay, {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
session_id: 'root-session',
|
||||
prompt: 'coordinate reviewers',
|
||||
model: 'gpt-5.4'
|
||||
})
|
||||
for (const id of ['child-a', 'child-b']) {
|
||||
send(initialRelay, {
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: id,
|
||||
agent_type: 'reviewer',
|
||||
model: 'gpt-5.4-mini'
|
||||
})
|
||||
}
|
||||
|
||||
const restartedRelay = createHookListenerState()
|
||||
send(restartedRelay, { hook_event_name: 'SubagentStop', agent_id: 'child-a' })
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
model: 'gpt-5.4',
|
||||
providerSession: { key: 'session_id', id: 'root-session' },
|
||||
subagents: [expect.objectContaining({ id: 'child-b' })]
|
||||
})
|
||||
|
||||
send(restartedRelay, {
|
||||
hook_event_name: 'Stop',
|
||||
session_id: 'root-session',
|
||||
model: 'gpt-5.4'
|
||||
})
|
||||
const restartedAgain = createHookListenerState()
|
||||
send(restartedAgain, { hook_event_name: 'SubagentStop', agent_id: 'child-b' })
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
model: 'gpt-5.4',
|
||||
providerSession: { key: 'session_id', id: 'root-session' },
|
||||
subagents: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('does not carry a remote Codex roster across connection cleanup', () => {
|
||||
const server = new AgentHookServer()
|
||||
const child = (id: string) => ({
|
||||
id,
|
||||
state: 'working' as const,
|
||||
startedAt: 1
|
||||
})
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
hookEventName: 'SubagentStart',
|
||||
toolAgentId: 'old-child',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
subagents: [child('old-child')]
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
server.clearStatusEntriesForConnection('conn-1')
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
hookEventName: 'SubagentStart',
|
||||
toolAgentId: 'new-child',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
subagents: [child('new-child')]
|
||||
}
|
||||
},
|
||||
'conn-2'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]?.subagents).toEqual([child('new-child')])
|
||||
})
|
||||
|
||||
it('retains root Codex identity when relay child events omit it', () => {
|
||||
const server = new AgentHookServer()
|
||||
const providerSession = { key: 'session_id' as const, id: 'root-session' }
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
providerSession,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'coordinate reviewers',
|
||||
agentType: 'codex',
|
||||
model: 'gpt-5.4'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
toolAgentId: 'child-session',
|
||||
payload: { state: 'waiting', prompt: 'coordinate reviewers', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
model: 'gpt-5.4',
|
||||
providerSession
|
||||
})
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
hookEventName: 'SubagentStop',
|
||||
toolAgentId: 'child-session',
|
||||
payload: { state: 'done', prompt: 'coordinate reviewers', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
model: 'gpt-5.4',
|
||||
providerSession
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Codex lead state terminal after an inferred interrupt', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
@@ -102,7 +244,13 @@ describe('AgentHookServer listener replay', () => {
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' },
|
||||
payload: { state: 'working', prompt: 'long task', agentType: 'codex' }
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'long task',
|
||||
agentType: 'codex',
|
||||
model: 'gpt-5.6-sol'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
@@ -138,6 +286,25 @@ describe('AgentHookServer listener replay', () => {
|
||||
payload: expect.objectContaining({ state: 'done', interrupted: true })
|
||||
})
|
||||
)
|
||||
|
||||
vi.setSystemTime(17_000)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hookEventName: 'SubagentStop',
|
||||
toolAgentId: 'delayed-child',
|
||||
payload: { state: 'done', prompt: 'long task', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
model: 'gpt-5.6-sol',
|
||||
prompt: 'long task'
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -4209,6 +4376,109 @@ describe('Claude hook normalization', () => {
|
||||
})
|
||||
|
||||
describe('Codex hook normalization', () => {
|
||||
it('tracks nested subagents with their role, model, and lifecycle state', () => {
|
||||
const root = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'Coordinate the review',
|
||||
model: 'gpt-5.4'
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(root?.payload.model).toBe('gpt-5.4')
|
||||
|
||||
const started = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({
|
||||
hook_event_name: 'SubagentStart',
|
||||
session_id: 'child-session',
|
||||
agent_id: 'child-session',
|
||||
agent_type: 'reviewer',
|
||||
model: 'gpt-5.4-mini'
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(started?.providerSession).toBeUndefined()
|
||||
expect(started?.payload).toMatchObject({
|
||||
state: 'working',
|
||||
prompt: 'Coordinate the review',
|
||||
model: 'gpt-5.4',
|
||||
subagents: [
|
||||
{
|
||||
id: 'child-session',
|
||||
agentType: 'reviewer',
|
||||
model: 'gpt-5.4-mini',
|
||||
state: 'working'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const waiting = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'child-session',
|
||||
agent_type: 'reviewer',
|
||||
model: 'gpt-5.4-mini',
|
||||
tool_name: 'exec_command',
|
||||
tool_input: { cmd: 'git fetch' }
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(waiting?.payload.state).toBe('waiting')
|
||||
expect(waiting?.payload.subagents?.[0].state).toBe('waiting')
|
||||
|
||||
const workingAgain = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({
|
||||
hook_event_name: 'PreToolUse',
|
||||
agent_id: 'child-session',
|
||||
agent_type: 'reviewer',
|
||||
model: 'gpt-5.4-mini',
|
||||
tool_name: 'exec_command',
|
||||
tool_input: { cmd: 'git fetch' }
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(workingAgain?.payload.state).toBe('working')
|
||||
expect(workingAgain?.payload.subagents?.[0].state).toBe('working')
|
||||
|
||||
const rootStop = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }),
|
||||
'production'
|
||||
)
|
||||
expect(rootStop?.payload.state).toBe('done')
|
||||
expect(rootStop?.payload.subagents).toBeUndefined()
|
||||
|
||||
const resumedChild = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({
|
||||
hook_event_name: 'PreToolUse',
|
||||
agent_id: 'child-session',
|
||||
agent_type: 'reviewer',
|
||||
model: 'gpt-5.4-mini',
|
||||
tool_name: 'exec_command',
|
||||
tool_input: { cmd: 'pnpm test' }
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(resumedChild?.payload.state).toBe('working')
|
||||
expect(resumedChild?.payload.subagents?.[0]).toMatchObject({
|
||||
id: 'child-session',
|
||||
state: 'working'
|
||||
})
|
||||
|
||||
const stopped = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'child-session' }),
|
||||
'production'
|
||||
)
|
||||
expect(stopped?.payload.state).toBe('done')
|
||||
expect(stopped?.payload.subagents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('Stop carries last_assistant_message into lastAssistantMessage', () => {
|
||||
const result = _internals.normalizeHookPayload(
|
||||
'codex',
|
||||
@@ -6243,6 +6513,90 @@ describe('Last-status persistence', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('restores Codex child hierarchy and reaps unconfirmed children on the next root Stop', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
const receivedAt = recentTs()
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
[PANE]: {
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
receivedAt,
|
||||
stateStartedAt: recentTs(-1000),
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'coordinate reviews',
|
||||
agentType: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
subagents: [
|
||||
{
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
state: 'working',
|
||||
startedAt: receivedAt - 5000,
|
||||
agentType: 'reviewer',
|
||||
model: 'gpt-5.4-mini'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
model: 'gpt-5.4',
|
||||
subagents: [
|
||||
expect.objectContaining({
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
model: 'gpt-5.4-mini'
|
||||
})
|
||||
]
|
||||
})
|
||||
])
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({
|
||||
hook_event_name: 'PreToolUse',
|
||||
agent_id: '11111111-2222-4333-8444-555555555555',
|
||||
agent_type: 'reviewer',
|
||||
model: 'gpt-5.4-mini',
|
||||
tool_name: 'exec_command',
|
||||
tool_input: { cmd: 'pnpm test' }
|
||||
}),
|
||||
'/hook/codex'
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
model: 'gpt-5.4',
|
||||
subagents: [expect.objectContaining({ model: 'gpt-5.4-mini' })]
|
||||
})
|
||||
])
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }),
|
||||
'/hook/codex'
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ state: 'done', model: 'gpt-5.4', subagents: undefined })
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a corrupt file as empty hydration without throwing', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
writeFileSync(lastStatusPath(), 'not-json{{', 'utf8')
|
||||
|
||||
@@ -18,14 +18,17 @@ import {
|
||||
hasPendingAgentResultText,
|
||||
HOOK_REQUEST_SLOWLORIS_MS,
|
||||
markClaudeLeadTurnInterrupted,
|
||||
markCodexLeadTurnInterrupted,
|
||||
MAX_PANE_KEY_LEN,
|
||||
movePaneCacheState,
|
||||
normalizeHookPayload,
|
||||
parseFormEncodedBody,
|
||||
readRequestBody,
|
||||
reconcileRemoteCodexState,
|
||||
resolveHookSource,
|
||||
preparePendingGrokResultDiscovery,
|
||||
seedClaudeSubagentRosterFromSnapshots,
|
||||
seedCodexStateFromSnapshot,
|
||||
warnOnHookEnvOrVersionMismatch,
|
||||
writeEndpointFile,
|
||||
type AgentHookEventPayload,
|
||||
@@ -162,11 +165,11 @@ function dropHydratedIdleClaudeSubagents(
|
||||
) {
|
||||
return payload
|
||||
}
|
||||
const workingSubagents = payload.subagents.filter((subagent) => subagent.state === 'working')
|
||||
const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle')
|
||||
// Why: older builds persisted finished Claude children as idle rows; prune them so restart can't resurrect the pile.
|
||||
return {
|
||||
...payload,
|
||||
subagents: workingSubagents.length > 0 ? workingSubagents : undefined
|
||||
subagents: activeSubagents.length > 0 ? activeSubagents : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +274,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
|
||||
}
|
||||
}
|
||||
|
||||
// Why: OSC-only dedupe; omits `subagents` (OSC never carries them) so an OSC ping can't wipe the hook-cached roster. Don't reuse for hook comparisons.
|
||||
// Why: OSC never carries model/children; omit both so an equivalent OSC ping preserves the hook-cached identity graph.
|
||||
function equivalentParsedAgentStatusPayload(
|
||||
a: ParsedAgentStatusPayload,
|
||||
b: ParsedAgentStatusPayload
|
||||
@@ -560,7 +563,7 @@ export class AgentHookServer {
|
||||
return false
|
||||
}
|
||||
// Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows.
|
||||
if (payload.subagents?.some((subagent) => subagent.state === 'working')) {
|
||||
if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -568,6 +571,9 @@ export class AgentHookServer {
|
||||
if (agentType === 'claude') {
|
||||
markClaudeLeadTurnInterrupted(this.state, existing.paneKey)
|
||||
}
|
||||
if (agentType === 'codex') {
|
||||
markCodexLeadTurnInterrupted(this.state, existing.paneKey)
|
||||
}
|
||||
const inferred = this.applyNormalizedStatus({
|
||||
paneKey: existing.paneKey,
|
||||
tabId: existing.tabId,
|
||||
@@ -578,6 +584,7 @@ export class AgentHookServer {
|
||||
state: 'done',
|
||||
prompt: payload.prompt,
|
||||
agentType,
|
||||
...(payload.model ? { model: payload.model } : {}),
|
||||
interrupted: true,
|
||||
// Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore.
|
||||
...(payload.subagents ? { subagents: payload.subagents } : {})
|
||||
@@ -835,6 +842,43 @@ export class AgentHookServer {
|
||||
this.onAgentStatus?.(enriched)
|
||||
return enriched
|
||||
}
|
||||
const stateReconciledPayload =
|
||||
payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName
|
||||
? {
|
||||
...payload,
|
||||
payload: reconcileRemoteCodexState(
|
||||
this.state,
|
||||
payload.paneKey,
|
||||
payload.hookEventName,
|
||||
payload.toolAgentId,
|
||||
payload.payload,
|
||||
previous?.payload
|
||||
)
|
||||
}
|
||||
: payload
|
||||
const previousCodexRoot =
|
||||
stateReconciledPayload.payload.agentType === 'codex' &&
|
||||
stateReconciledPayload.toolAgentId &&
|
||||
previous?.payload.agentType === 'codex'
|
||||
? previous
|
||||
: undefined
|
||||
const preservedProviderSession = !stateReconciledPayload.providerSession
|
||||
? previousCodexRoot?.providerSession
|
||||
: undefined
|
||||
const preservedRootModel = !stateReconciledPayload.payload.model
|
||||
? previousCodexRoot?.payload.model
|
||||
: undefined
|
||||
// Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity.
|
||||
const rootContextPreservingPayload =
|
||||
preservedProviderSession || preservedRootModel
|
||||
? {
|
||||
...stateReconciledPayload,
|
||||
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
|
||||
payload: preservedRootModel
|
||||
? { ...stateReconciledPayload.payload, model: preservedRootModel }
|
||||
: stateReconciledPayload.payload
|
||||
}
|
||||
: stateReconciledPayload
|
||||
const identity = resolveAgentStatusIdentity({
|
||||
existing: previous
|
||||
? {
|
||||
@@ -843,25 +887,25 @@ export class AgentHookServer {
|
||||
updatedAt: previous.receivedAt
|
||||
}
|
||||
: undefined,
|
||||
incoming: payload.payload.agentType,
|
||||
incoming: rootContextPreservingPayload.payload.agentType,
|
||||
now
|
||||
})
|
||||
if (
|
||||
previous &&
|
||||
shouldSuppressInheritedTerminalStatus({
|
||||
inheritedFromActivePane: identity.inheritedFromActivePane,
|
||||
incomingState: payload.payload.state
|
||||
incomingState: rootContextPreservingPayload.payload.state
|
||||
})
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
const identityResolvedPayload =
|
||||
identity.agentType === payload.payload.agentType
|
||||
? payload
|
||||
identity.agentType === rootContextPreservingPayload.payload.agentType
|
||||
? rootContextPreservingPayload
|
||||
: {
|
||||
...payload,
|
||||
...rootContextPreservingPayload,
|
||||
payload: {
|
||||
...payload.payload,
|
||||
...rootContextPreservingPayload.payload,
|
||||
agentType: identity.agentType
|
||||
}
|
||||
}
|
||||
@@ -1598,8 +1642,14 @@ export class AgentHookServer {
|
||||
if (entry.connectionId !== normalizedConnectionId) {
|
||||
continue
|
||||
}
|
||||
if (this.deleteStatusEntry(paneKey)) {
|
||||
const deleted = this.deleteStatusEntry(paneKey)
|
||||
if (deleted) {
|
||||
statusChanged = true
|
||||
if (deleted.payload.agentType === 'codex') {
|
||||
// Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children.
|
||||
this.state.codexSubagentRosterByPaneKey.delete(paneKey)
|
||||
this.state.codexLeadStateByPaneKey.delete(paneKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (statusChanged) {
|
||||
@@ -1840,8 +1890,10 @@ export class AgentHookServer {
|
||||
Math.max(previousWatermark ?? -1, entry.receivedAt)
|
||||
)
|
||||
}
|
||||
// Why: seed only working children across restart; a later full inventory reaps stale ones.
|
||||
if (entry.payload.subagents) {
|
||||
// Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds.
|
||||
if (entry.payload.agentType === 'codex') {
|
||||
seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload)
|
||||
} else if (entry.payload.agentType === 'claude' && entry.payload.subagents) {
|
||||
seedClaudeSubagentRosterFromSnapshots(
|
||||
this.state,
|
||||
resolvedPaneKey,
|
||||
|
||||
@@ -75,6 +75,8 @@ const aiVaultListResultSchema = z.object({
|
||||
messageCount: z.number(),
|
||||
totalTokens: z.number(),
|
||||
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
|
||||
// Optional keeps paired hosts on older builds compatible.
|
||||
lastUserPrompt: z.string().nullable().optional(),
|
||||
// Default keeps remote hosts running an older build (no recoverable-signal
|
||||
// fields) parseable; they simply report no recoverable-empty sessions.
|
||||
queuedMessageCount: z.number().default(0),
|
||||
|
||||
@@ -41,6 +41,7 @@ export function createAccumulator(args: {
|
||||
messageCount: 0,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
lastUserPrompt: null,
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
latestTimestampMs: 0
|
||||
@@ -112,6 +113,7 @@ export function finalizeSession(
|
||||
messageCount: accumulator.messageCount,
|
||||
totalTokens: accumulator.totalTokens,
|
||||
previewMessages: accumulator.previewMessages,
|
||||
...(accumulator.lastUserPrompt ? { lastUserPrompt: accumulator.lastUserPrompt } : {}),
|
||||
queuedMessageCount: accumulator.queuedMessageCount,
|
||||
subagentTranscriptCount: accumulator.subagentTranscriptCount,
|
||||
resumeCommand: buildAiVaultResumeCommand({
|
||||
|
||||
@@ -16,6 +16,51 @@ function jsonLines(records: unknown[]): string {
|
||||
}
|
||||
|
||||
describe('parseCodexSessionFile', () => {
|
||||
it('uses user-message events instead of later injected user-role records', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-last-prompt-'))
|
||||
tempRoots.push(root)
|
||||
const sessionPath = join(root, 'sessions', '2026', '07', '21', 'rollout-last-prompt.jsonl')
|
||||
await mkdir(dirname(sessionPath), { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
sessionPath,
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-21T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'last-prompt-session', cwd: '/repo/app' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-21T10:00:01.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'Review the PR and fix real regressions' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-21T10:00:02.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: '<skill>performance instructions</skill>' }]
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const sessionStat = await stat(sessionPath)
|
||||
const session = await parseCodexSessionFile(
|
||||
{
|
||||
path: sessionPath,
|
||||
mtimeMs: sessionStat.mtimeMs,
|
||||
modifiedAt: sessionStat.mtime.toISOString()
|
||||
},
|
||||
'darwin',
|
||||
root
|
||||
)
|
||||
|
||||
expect(session?.lastUserPrompt).toBe('Review the PR and fix real regressions')
|
||||
})
|
||||
|
||||
it('does not double-count usage when token count formats switch', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-token-switch-'))
|
||||
tempRoots.push(root)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { normalizePromptField } from '../../shared/agent-status-field-normalization'
|
||||
import {
|
||||
addPreviewContent,
|
||||
cloneSessionAccumulator,
|
||||
@@ -179,6 +180,10 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo
|
||||
|
||||
if (payload.type === 'user_message') {
|
||||
accumulator.messageCount++
|
||||
const prompt = normalizePromptField(payload.message)
|
||||
if (prompt) {
|
||||
accumulator.lastUserPrompt = prompt
|
||||
}
|
||||
if (!accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.message)
|
||||
state.titleSource = accumulator.title ? 'user' : state.titleSource
|
||||
|
||||
@@ -91,4 +91,48 @@ describe('scanAiVaultSessions harness-injected title seeding', () => {
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]?.title).toBe('<my-custom-element> render the profile card')
|
||||
})
|
||||
|
||||
it('uses Claude last-prompt metadata instead of injected and tool-result user records', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-claude-last-prompt-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
join(roots.claudeProjectsDir, 'project', 'last-prompt.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'last-prompt',
|
||||
timestamp: '2026-06-11T10:00:00.000Z',
|
||||
cwd: '/repo/app',
|
||||
isMeta: true,
|
||||
message: { role: 'user', content: 'Base directory for this skill: /tmp/skills' }
|
||||
},
|
||||
{
|
||||
type: 'last-prompt',
|
||||
sessionId: 'last-prompt',
|
||||
lastPrompt: 'Fix the zoom behavior in a separate PR'
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'last-prompt',
|
||||
timestamp: '2026-06-11T10:00:01.000Z',
|
||||
cwd: '/repo/app',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', content: 'src/main/window.ts was updated' }]
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions[0]?.lastUserPrompt).toBe('Fix the zoom behavior in a separate PR')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns'
|
||||
import { normalizePromptField } from '../../shared/agent-status-field-normalization'
|
||||
import type {
|
||||
FileWithMtime,
|
||||
ResumableSessionParseState,
|
||||
@@ -115,6 +116,14 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s
|
||||
return
|
||||
}
|
||||
|
||||
if (record.type === 'last-prompt') {
|
||||
const prompt = normalizePromptField(record.lastPrompt)
|
||||
if (prompt) {
|
||||
accumulator.lastUserPrompt = prompt
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (record.type === 'user') {
|
||||
accumulator.messageCount++
|
||||
const title = extractMessageText(record.message)
|
||||
|
||||
@@ -110,6 +110,7 @@ export type SessionAccumulator = {
|
||||
messageCount: number
|
||||
totalTokens: number
|
||||
previewMessages: AiVaultSessionPreviewMessage[]
|
||||
lastUserPrompt: string | null
|
||||
// Recoverable signal for a zero-turn transcript (see AiVaultSession).
|
||||
queuedMessageCount: number
|
||||
subagentTranscriptCount: number
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { CodexEventLabel, CodexTrustEntry } from './config-toml-trust'
|
||||
export const CODEX_HOOK_EVENT_LABEL: Record<string, CodexEventLabel> = {
|
||||
SessionStart: 'session_start',
|
||||
UserPromptSubmit: 'user_prompt_submit',
|
||||
SubagentStart: 'subagent_start',
|
||||
SubagentStop: 'subagent_stop',
|
||||
PreToolUse: 'pre_tool_use',
|
||||
PermissionRequest: 'permission_request',
|
||||
PostToolUse: 'post_tool_use',
|
||||
@@ -19,6 +21,8 @@ export const CODEX_HOOK_EVENT_LABEL: Record<string, CodexEventLabel> = {
|
||||
export const CODEX_EVENT_NAME_BY_LABEL: Record<CodexEventLabel, string> = {
|
||||
session_start: 'SessionStart',
|
||||
user_prompt_submit: 'UserPromptSubmit',
|
||||
subagent_start: 'SubagentStart',
|
||||
subagent_stop: 'SubagentStop',
|
||||
pre_tool_use: 'PreToolUse',
|
||||
permission_request: 'PermissionRequest',
|
||||
post_tool_use: 'PostToolUse',
|
||||
|
||||
@@ -31,6 +31,8 @@ export type CodexEventLabel =
|
||||
| 'post_compact'
|
||||
| 'session_start'
|
||||
| 'user_prompt_submit'
|
||||
| 'subagent_start'
|
||||
| 'subagent_stop'
|
||||
| 'stop'
|
||||
|
||||
export type CodexTrustEntry = {
|
||||
@@ -114,6 +116,8 @@ function matcherPatternForEvent(
|
||||
case 'pre_compact':
|
||||
case 'post_compact':
|
||||
case 'session_start':
|
||||
case 'subagent_start':
|
||||
case 'subagent_stop':
|
||||
return matcher
|
||||
}
|
||||
}
|
||||
@@ -315,6 +319,8 @@ function isCodexEventLabel(value: string): value is CodexEventLabel {
|
||||
value === 'post_compact' ||
|
||||
value === 'session_start' ||
|
||||
value === 'user_prompt_submit' ||
|
||||
value === 'subagent_start' ||
|
||||
value === 'subagent_stop' ||
|
||||
value === 'stop'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('CodexHookService app-server trust grant lane', () => {
|
||||
timeoutSec: 10
|
||||
})
|
||||
expect(trustConfig).not.toContain(selfComputed)
|
||||
expect(Object.keys(readCodexTrustGrantLedgerHome(managedHome)!.entries)).toHaveLength(6)
|
||||
expect(Object.keys(readCodexTrustGrantLedgerHome(managedHome)!.entries)).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('keeps config byte-stable and skips the session on a repeat ledger hit', () => {
|
||||
|
||||
@@ -33,6 +33,8 @@ const managedEvents = [
|
||||
'PreToolUse',
|
||||
'PermissionRequest',
|
||||
'PostToolUse',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'Stop'
|
||||
] as const
|
||||
|
||||
|
||||
@@ -123,6 +123,8 @@ function localManagedCodexEvents(): string[] {
|
||||
'PreToolUse',
|
||||
'SessionStart',
|
||||
'Stop',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'UserPromptSubmit'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ const CODEX_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PermissionRequest',
|
||||
'PostToolUse',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'Stop'
|
||||
] as const
|
||||
|
||||
@@ -112,6 +114,8 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel>
|
||||
PreToolUse: CODEX_HOOK_EVENT_LABEL.PreToolUse!,
|
||||
PermissionRequest: CODEX_HOOK_EVENT_LABEL.PermissionRequest!,
|
||||
PostToolUse: CODEX_HOOK_EVENT_LABEL.PostToolUse!,
|
||||
SubagentStart: CODEX_HOOK_EVENT_LABEL.SubagentStart!,
|
||||
SubagentStop: CODEX_HOOK_EVENT_LABEL.SubagentStop!,
|
||||
Stop: CODEX_HOOK_EVENT_LABEL.Stop!
|
||||
}
|
||||
|
||||
|
||||
@@ -730,7 +730,12 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
|
||||
describe('listProcesses', () => {
|
||||
it('returns active sessions', async () => {
|
||||
await adapter.spawn({ cols: 80, rows: 24, cwd: '/repo/owned-before-osc7' })
|
||||
await adapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/repo/owned-before-osc7',
|
||||
worktreeId: 'repo::/repo/owned-before-osc7'
|
||||
})
|
||||
await adapter.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
const procs = await adapter.listProcesses()
|
||||
@@ -739,6 +744,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(procs[0]).toHaveProperty('cwd')
|
||||
expect(procs[0]).toHaveProperty('title')
|
||||
expect(procs[0].cwd).toBe('/repo/owned-before-osc7')
|
||||
expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -773,13 +773,17 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
)
|
||||
return result.sessions
|
||||
.filter((s) => s.isAlive)
|
||||
.map((s) => ({
|
||||
id: s.sessionId,
|
||||
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
|
||||
cwd: s.cwd ?? this.initialCwds.get(s.sessionId) ?? '',
|
||||
title: 'shell',
|
||||
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {})
|
||||
}))
|
||||
.map((s) => {
|
||||
const { worktreeId } = parsePtySessionId(s.sessionId)
|
||||
return {
|
||||
id: s.sessionId,
|
||||
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
|
||||
cwd: s.cwd ?? this.initialCwds.get(s.sessionId) ?? '',
|
||||
title: 'shell',
|
||||
...(worktreeId ? { worktreeId } : {}),
|
||||
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why keep both: the Manage Sessions panel needs full SessionInfo (pid/state/createdAt) that listProcesses drops for the IPtyProvider contract.
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
forceDeleteLocalBranch,
|
||||
listWorktrees,
|
||||
removeWorktree,
|
||||
WORKTREE_LIST_TIMEOUT_MS,
|
||||
WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS
|
||||
} from './worktree'
|
||||
|
||||
@@ -1009,7 +1010,8 @@ describe('listWorktrees', () => {
|
||||
])
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], {
|
||||
cwd: 'C:\\Users\\me\\repo',
|
||||
wslDistro: 'Ubuntu'
|
||||
wslDistro: 'Ubuntu',
|
||||
timeout: WORKTREE_LIST_TIMEOUT_MS
|
||||
})
|
||||
expect(translateWslOutputPathsMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
@@ -1030,7 +1032,8 @@ describe('listWorktrees', () => {
|
||||
await expect(listWorktrees('/workspace/deleted-repo')).resolves.toEqual([])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], {
|
||||
cwd: '/workspace/deleted-repo'
|
||||
cwd: '/workspace/deleted-repo',
|
||||
timeout: WORKTREE_LIST_TIMEOUT_MS
|
||||
})
|
||||
expect(statMock).toHaveBeenCalledWith('/workspace/deleted-repo')
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
@@ -1052,7 +1055,8 @@ describe('listWorktrees', () => {
|
||||
await expect(listWorktrees('/private/tmp/orca-issue-1582-test/my-repo')).resolves.toEqual([])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], {
|
||||
cwd: '/private/tmp/orca-issue-1582-test/my-repo'
|
||||
cwd: '/private/tmp/orca-issue-1582-test/my-repo',
|
||||
timeout: WORKTREE_LIST_TIMEOUT_MS
|
||||
})
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
moveWorktree,
|
||||
parseWorktreeList,
|
||||
removeWorktree,
|
||||
WORKTREE_ADD_TIMEOUT_MS
|
||||
WORKTREE_ADD_TIMEOUT_MS,
|
||||
WORKTREE_LIST_TIMEOUT_MS
|
||||
} from './worktree'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -73,6 +74,21 @@ describe('listWorktrees in-flight sharing', () => {
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not share scans across different timeout contracts', async () => {
|
||||
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
|
||||
|
||||
await Promise.all([listWorktrees('/repo'), listWorktrees('/repo', { timeout: 5_000 })])
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[
|
||||
['worktree', 'list', '--porcelain', '-z'],
|
||||
{ cwd: '/repo', timeout: WORKTREE_LIST_TIMEOUT_MS }
|
||||
],
|
||||
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo', timeout: 5_000 }]
|
||||
])
|
||||
})
|
||||
|
||||
it('runs a fresh scan once the shared one has settled', async () => {
|
||||
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
|
||||
@@ -83,6 +99,27 @@ describe('listWorktrees in-flight sharing', () => {
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('runs a fresh scan after a timed-out shared scan settles', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
try {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('git timed out.'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
})
|
||||
|
||||
await expect(listWorktrees('/repo')).resolves.toEqual([])
|
||||
await expect(listWorktrees('/repo')).resolves.toEqual([
|
||||
expect.objectContaining({ path: '/repo', head: 'abc123' })
|
||||
])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not share scans across different repos', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation((_args: string[], options: { cwd: string }) =>
|
||||
Promise.resolve({
|
||||
@@ -551,7 +588,8 @@ branch refs/heads/feature/test
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], {
|
||||
cwd: '/repo'
|
||||
cwd: '/repo',
|
||||
timeout: WORKTREE_LIST_TIMEOUT_MS
|
||||
})
|
||||
})
|
||||
|
||||
@@ -595,8 +633,11 @@ branch refs/heads/main-2
|
||||
])
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
|
||||
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
|
||||
[
|
||||
['worktree', 'list', '--porcelain', '-z'],
|
||||
{ cwd: '/repo', timeout: WORKTREE_LIST_TIMEOUT_MS }
|
||||
],
|
||||
[['worktree', 'list', '--porcelain'], { cwd: '/repo', timeout: WORKTREE_LIST_TIMEOUT_MS }]
|
||||
])
|
||||
})
|
||||
|
||||
@@ -628,8 +669,11 @@ branch refs/heads/main
|
||||
])
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
|
||||
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
|
||||
[
|
||||
['worktree', 'list', '--porcelain', '-z'],
|
||||
{ cwd: '/repo', timeout: WORKTREE_LIST_TIMEOUT_MS }
|
||||
],
|
||||
[['worktree', 'list', '--porcelain'], { cwd: '/repo', timeout: WORKTREE_LIST_TIMEOUT_MS }]
|
||||
])
|
||||
})
|
||||
|
||||
@@ -646,7 +690,10 @@ branch refs/heads/main
|
||||
await expect(listWorktreeGraph('/repo')).resolves.toEqual([])
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls).toEqual([
|
||||
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }]
|
||||
[
|
||||
['worktree', 'list', '--porcelain', '-z'],
|
||||
{ cwd: '/repo', timeout: WORKTREE_LIST_TIMEOUT_MS }
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
@@ -655,6 +702,20 @@ branch refs/heads/main
|
||||
|
||||
await expect(listWorktreeGraph('/not-a-repo')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('lets callers override the default worktree list timeout', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
|
||||
})
|
||||
|
||||
await listWorktreeGraph('/repo', { timeout: 5_000 })
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], {
|
||||
cwd: '/repo',
|
||||
timeout: 5_000
|
||||
})
|
||||
expect(WORKTREE_LIST_TIMEOUT_MS).toBe(30_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addWorktree', () => {
|
||||
|
||||
@@ -87,6 +87,8 @@ const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
|
||||
// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); generous enough for a legit large checkout (#7225).
|
||||
export const WORKTREE_ADD_TIMEOUT_MS = 180_000
|
||||
export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000
|
||||
// Why: one wedged shared scan otherwise hangs every later list, including create's post-add re-list.
|
||||
export const WORKTREE_LIST_TIMEOUT_MS = 30_000
|
||||
|
||||
function gitExecOptions(
|
||||
cwd: string,
|
||||
@@ -572,13 +574,18 @@ async function readWorktreeList(
|
||||
cwd: repoPath,
|
||||
wslDistro: options.wslDistro
|
||||
})
|
||||
const execOptions = {
|
||||
cwd: repoPath,
|
||||
...options,
|
||||
timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS
|
||||
}
|
||||
return capabilities.runWithFallback(
|
||||
'worktree-list-z',
|
||||
async () => {
|
||||
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
|
||||
cwd: repoPath,
|
||||
...options
|
||||
})
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['worktree', 'list', '--porcelain', '-z'],
|
||||
execOptions
|
||||
)
|
||||
return normalizeMainWorktreePath(
|
||||
repoPath,
|
||||
parseWorktreeList(stdout, { nulDelimited: true }),
|
||||
@@ -587,10 +594,7 @@ async function readWorktreeList(
|
||||
},
|
||||
async () => {
|
||||
// Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser.
|
||||
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], {
|
||||
cwd: repoPath,
|
||||
...options
|
||||
})
|
||||
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], execOptions)
|
||||
const normalized = await normalizeMainWorktreePath(
|
||||
repoPath,
|
||||
parseWorktreeList(stdout),
|
||||
@@ -737,7 +741,9 @@ export function listWorktrees(
|
||||
return listWorktreesUnshared(repoPath, options)
|
||||
}
|
||||
const generation = worktreeScanGenerations.get(repoPath) ?? 0
|
||||
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${generation}`
|
||||
const timeout = options.timeout ?? WORKTREE_LIST_TIMEOUT_MS
|
||||
// Why: callers with different deadlines cannot safely share which timeout wins the scan.
|
||||
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${timeout}\0${generation}`
|
||||
const inFlight = inFlightWorktreeScans.get(key)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
|
||||
@@ -98,12 +98,7 @@ export async function getGitHubApiRepositoryForRemote(
|
||||
): Promise<GitHubApiRepository | null> {
|
||||
// Why: generic PR resolution prefers upstream, but this API represents the
|
||||
// caller-selected remote exactly (#7331).
|
||||
const ownerRepo = await getOwnerRepoForRemote(
|
||||
repoPath,
|
||||
remoteName,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const ownerRepo = await getOwnerRepoForRemote(repoPath, remoteName, connectionId, localGitOptions)
|
||||
if (ownerRepo) {
|
||||
return { ...ownerRepo, host: 'github.com' }
|
||||
}
|
||||
|
||||
+60
-52
@@ -42,6 +42,9 @@ import { resolveConsent } from './telemetry/consent'
|
||||
import { triggerStartupNotificationRegistration } from './ipc/notifications'
|
||||
import { OrcaRuntimeService } from './runtime/orca-runtime'
|
||||
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
|
||||
import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint'
|
||||
import { ServeReadinessPublisher } from './server/serve-readiness'
|
||||
import { reserveServeStdoutForReadiness } from './server/serve-stdout-boundary'
|
||||
import { DesktopRelayService } from './runtime/relay/desktop-relay-service'
|
||||
import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker'
|
||||
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
|
||||
@@ -52,9 +55,13 @@ import {
|
||||
registerAppMenu,
|
||||
rebuildAppMenu
|
||||
} from './menu/register-app-menu'
|
||||
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
|
||||
import { checkForUpdatesFromMenu, isQuittingForUpdate, resolveUpdateInstallMode } from './updater'
|
||||
import type { TuiAgent, UpdateCheckOptions } from '../shared/types'
|
||||
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
||||
import {
|
||||
installServeSupervisorDisconnectQuit,
|
||||
notifyServeSupervisorReady
|
||||
} from './serve-update-handoff'
|
||||
import {
|
||||
configureElectronNetworkCompatibility,
|
||||
configureDevUserDataPath,
|
||||
@@ -119,6 +126,7 @@ import {
|
||||
ensureAutoUpdaterConfigured
|
||||
} from './window/attach-main-window-services'
|
||||
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
|
||||
import { zoomDashboardPopoutIfFocused } from './window/dashboard-popout-window'
|
||||
import {
|
||||
createSystemTray,
|
||||
destroySystemTray,
|
||||
@@ -241,6 +249,7 @@ let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null
|
||||
let runtime: OrcaRuntimeService | null = null
|
||||
let rateLimits: RateLimitService | null = null
|
||||
let runtimeRpc: OrcaRuntimeRpcServer | null = null
|
||||
const serveReadinessPublisher = new ServeReadinessPublisher()
|
||||
let desktopRelayService: DesktopRelayService | null = null
|
||||
let desktopRelayStatus: RelayBrokerStatus = 'offline'
|
||||
let pendingUnpairedDeviceAuthFailure = false
|
||||
@@ -277,6 +286,9 @@ let localPtyStartupReady: Promise<void> = Promise.resolve()
|
||||
let localPtyProviderStartupReady: Promise<void> = Promise.resolve()
|
||||
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
|
||||
const isServeMode = process.argv.includes('--serve')
|
||||
if (isServeMode) {
|
||||
reserveServeStdoutForReadiness()
|
||||
}
|
||||
const desktopActivationGate = createServeDesktopActivationGate({
|
||||
initialState: isServeMode ? 'initializing' : 'ready',
|
||||
activateWindow: () => {
|
||||
@@ -452,6 +464,7 @@ if (app.isPackaged && process.platform !== 'win32') {
|
||||
}
|
||||
configureDevUserDataPath(is.dev)
|
||||
configureOrcaUserDataPathEnv()
|
||||
installServeSupervisorDisconnectQuit(isServeMode)
|
||||
|
||||
// Why: just past createMainWindow's 10s ready-to-show fallback, so a window revealed that way still gets its tray icon.
|
||||
const TRAY_CREATE_FALLBACK_MS = 12_000
|
||||
@@ -1145,7 +1158,8 @@ function openMainWindow(): BrowserWindow {
|
||||
// Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787).
|
||||
isRecoveryReloadInFlight,
|
||||
onBeforeUpdateQuit: () =>
|
||||
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
|
||||
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }),
|
||||
updateInstallMode: resolveUpdateInstallMode(isServeMode)
|
||||
}
|
||||
)
|
||||
rateLimits.attach(window)
|
||||
@@ -1520,9 +1534,16 @@ async function printServeReady(options: ServeOptions): Promise<void> {
|
||||
throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`)
|
||||
}
|
||||
}
|
||||
const endpoint = runtimeRpc.getWebSocketEndpoint()
|
||||
const boundEndpoint = runtimeRpc.getWebSocketEndpoint()
|
||||
const advertised = boundEndpoint
|
||||
? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress)
|
||||
: null
|
||||
const pairing = options.noPairing
|
||||
? ({ available: false } as const)
|
||||
? ({
|
||||
available: false,
|
||||
reason: 'disabled_by_operator',
|
||||
guidance: 'Restart without --no-pairing to create a client pairing offer.'
|
||||
} as const)
|
||||
: runtimeRpc.createPairingOffer({
|
||||
address: options.pairingAddress,
|
||||
name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`,
|
||||
@@ -1532,51 +1553,30 @@ async function printServeReady(options: ServeOptions): Promise<void> {
|
||||
pairing.available && options.mobilePairing
|
||||
? await renderTerminalPairingQr(pairing.pairingUrl)
|
||||
: null
|
||||
if (options.recipeJson) {
|
||||
if (!pairing.available) {
|
||||
throw new Error('Recipe JSON output requires runtime pairing to be available')
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
pairingCode: pairing.pairingUrl,
|
||||
projectRoot: options.projectRoot
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: 'orca_server_ready',
|
||||
runtimeId: runtime.getRuntimeId(),
|
||||
endpoint,
|
||||
// Why: the WSL reconciliation barrier fails open, so 'pending' warns a WSL PTY launch may still race a repair.
|
||||
managedWslCliReconciliation: managedWslCliReconciliationStatus,
|
||||
pairing: pairing.available
|
||||
? {
|
||||
url: pairing.pairingUrl,
|
||||
endpoint: pairing.endpoint,
|
||||
deviceId: pairing.deviceId,
|
||||
webClientUrl: pairing.webClientUrl,
|
||||
scope: options.mobilePairing ? 'mobile' : 'runtime',
|
||||
qr: pairingQr
|
||||
}
|
||||
: null
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
console.log(`Orca server ready: ${endpoint ?? 'websocket unavailable'}`)
|
||||
if (pairing.available) {
|
||||
if (pairing.webClientUrl) {
|
||||
console.log(`Web client URL: ${pairing.webClientUrl}`)
|
||||
}
|
||||
if (options.mobilePairing && pairingQr) {
|
||||
console.log(`Mobile pairing QR:\n${pairingQr}`)
|
||||
}
|
||||
console.log(`Pairing URL: ${pairing.pairingUrl}`)
|
||||
}
|
||||
await serveReadinessPublisher.publish(
|
||||
{
|
||||
runtimeId: runtime.getRuntimeId(),
|
||||
boundEndpoint,
|
||||
advertisedEndpoint: advertised?.ok ? advertised.endpoint : null,
|
||||
// Why: the WSL reconciliation barrier fails open, so 'pending' warns a WSL PTY launch may still race a repair.
|
||||
managedWslCliReconciliation: managedWslCliReconciliationStatus,
|
||||
pairing: pairing.available
|
||||
? {
|
||||
available: true,
|
||||
url: pairing.pairingUrl,
|
||||
endpoint: pairing.endpoint,
|
||||
deviceId: pairing.deviceId,
|
||||
webClientUrl: pairing.webClientUrl,
|
||||
scope: options.mobilePairing ? 'mobile' : 'runtime',
|
||||
qr: pairingQr
|
||||
}
|
||||
: pairing
|
||||
},
|
||||
options.recipeJson
|
||||
? { mode: 'recipe-json', projectRoot: options.projectRoot! }
|
||||
: { mode: options.json ? 'json' : 'human' }
|
||||
)
|
||||
notifyServeSupervisorReady(runtime.getRuntimeId())
|
||||
}
|
||||
|
||||
function installServeSignalHandlers(): void {
|
||||
@@ -2196,14 +2196,22 @@ app.whenReady().then(async () => {
|
||||
const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null
|
||||
sendOpenFeatureTour(targetBrowserWindow)
|
||||
},
|
||||
// Why: menu zoom must act on the window the user is looking at — routing to
|
||||
// the main window while the dashboard pop-out is focused zooms behind it.
|
||||
onZoomIn: () => {
|
||||
mainWindow?.webContents.send('terminal:zoom', 'in')
|
||||
if (!zoomDashboardPopoutIfFocused('in')) {
|
||||
mainWindow?.webContents.send('terminal:zoom', 'in')
|
||||
}
|
||||
},
|
||||
onZoomOut: () => {
|
||||
mainWindow?.webContents.send('terminal:zoom', 'out')
|
||||
if (!zoomDashboardPopoutIfFocused('out')) {
|
||||
mainWindow?.webContents.send('terminal:zoom', 'out')
|
||||
}
|
||||
},
|
||||
onZoomReset: () => {
|
||||
mainWindow?.webContents.send('terminal:zoom', 'reset')
|
||||
if (!zoomDashboardPopoutIfFocused('reset')) {
|
||||
mainWindow?.webContents.send('terminal:zoom', 'reset')
|
||||
}
|
||||
},
|
||||
onToggleLeftSidebar: () => {
|
||||
mainWindow?.webContents.send('ui:toggleLeftSidebar')
|
||||
|
||||
@@ -106,7 +106,9 @@ describe('registerDashboardPopoutHandlers', () => {
|
||||
|
||||
store.getSettings.mockReturnValue({ experimentalAgentDashboardPopout: true })
|
||||
handlers.get('dashboardPopout:open')!({ sender: mainSender } as never)
|
||||
expect(createPopoutMock).toHaveBeenCalledWith(store)
|
||||
expect(createPopoutMock).toHaveBeenCalledWith(store, undefined, {
|
||||
getKeybindings: expect.any(Function)
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-closes the popout when the feature is disabled', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type { KeybindingService } from '../keybindings/keybinding-service'
|
||||
import type { DashboardSnapshot } from '../../shared/dashboard-snapshot'
|
||||
import {
|
||||
createOrFocusDashboardPopout,
|
||||
@@ -25,7 +26,10 @@ function isDashboardEnabled(store: Store): boolean {
|
||||
return store.getSettings().experimentalAgentDashboardPopout === true
|
||||
}
|
||||
|
||||
export function registerDashboardPopoutHandlers(store: Store): void {
|
||||
export function registerDashboardPopoutHandlers(
|
||||
store: Store,
|
||||
keybindings?: KeybindingService
|
||||
): void {
|
||||
ipcMain.removeHandler('dashboardPopout:open')
|
||||
ipcMain.removeHandler('dashboard:publishSnapshot')
|
||||
ipcMain.removeHandler('dashboard:requestSnapshot')
|
||||
@@ -52,7 +56,9 @@ export function registerDashboardPopoutHandlers(store: Store): void {
|
||||
if (!isTrustedUIRenderer(event.sender) || !isDashboardEnabled(store)) {
|
||||
return
|
||||
}
|
||||
createOrFocusDashboardPopout(store)
|
||||
createOrFocusDashboardPopout(store, undefined, {
|
||||
getKeybindings: () => keybindings?.getOverrides()
|
||||
})
|
||||
})
|
||||
|
||||
// Relay: the main renderer publishes derived snapshots; forward to the popout.
|
||||
|
||||
@@ -507,7 +507,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime)
|
||||
expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled()
|
||||
expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled()
|
||||
expect(registerDashboardPopoutHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerDashboardPopoutHandlersMock).toHaveBeenCalledWith(store, undefined)
|
||||
expect(registerTerminalPreviewHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService)
|
||||
expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store)
|
||||
|
||||
@@ -157,7 +157,7 @@ export function registerCoreHandlers(
|
||||
registerNotificationHandlers(store, runtime)
|
||||
registerNotebookHandlers(store)
|
||||
registerOnboardingHandlers(store)
|
||||
registerDashboardPopoutHandlers(store)
|
||||
registerDashboardPopoutHandlers(store, keybindings)
|
||||
registerTerminalPreviewHandlers(runtime)
|
||||
registerDeveloperPermissionHandlers()
|
||||
// Why: diagnostics handlers are wired alongside telemetry but the two
|
||||
|
||||
@@ -1758,7 +1758,12 @@ describe('LocalPtyProvider', () => {
|
||||
describe('listProcesses', () => {
|
||||
it('returns spawned PTYs', async () => {
|
||||
const before = await provider.listProcesses()
|
||||
await provider.spawn({ cols: 80, rows: 24, cwd: '/tmp/owned-cwd' })
|
||||
await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp/owned-cwd',
|
||||
worktreeId: 'repo::/tmp/owned-cwd'
|
||||
})
|
||||
await provider.spawn({ cols: 80, rows: 24 })
|
||||
const after = await provider.listProcesses()
|
||||
expect(after.length - before.length).toBe(2)
|
||||
@@ -1766,6 +1771,7 @@ describe('LocalPtyProvider', () => {
|
||||
expect(newEntries[0]).toHaveProperty('id')
|
||||
expect(newEntries[0]).toHaveProperty('title', 'zsh')
|
||||
expect(newEntries[0]).toHaveProperty('cwd', '/tmp/owned-cwd')
|
||||
expect(newEntries[0]).toHaveProperty('worktreeId', 'repo::/tmp/owned-cwd')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ const ptyAgentForegroundContextPaths = new Map<string, string[]>()
|
||||
// Why: remember the last recognized agent foreground so a degraded scan doesn't report the shell and look like an exit.
|
||||
const ptyLastRecognizedForeground = new Map<string, string>()
|
||||
const ptyTerminalHandle = new Map<string, string>()
|
||||
const ptyWorktreeId = new Map<string, string>()
|
||||
const ptyInitialCwd = new Map<string, string>()
|
||||
// Why: reattach carries current settings, not the live process's launch context; keep the first creator's WSL/native identity.
|
||||
const ptyWslDistroById = new Map<string, string | null>()
|
||||
@@ -240,6 +241,7 @@ function clearPtyState(id: string): void {
|
||||
ptyAgentForegroundContextPaths.delete(id)
|
||||
ptyLastRecognizedForeground.delete(id)
|
||||
ptyTerminalHandle.delete(id)
|
||||
ptyWorktreeId.delete(id)
|
||||
ptyInitialCwd.delete(id)
|
||||
ptyWslDistroById.delete(id)
|
||||
ptyLoadGeneration.delete(id)
|
||||
@@ -852,6 +854,9 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
if (finalEnv.ORCA_TERMINAL_HANDLE) {
|
||||
ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE)
|
||||
}
|
||||
if (args.worktreeId) {
|
||||
ptyWorktreeId.set(id, args.worktreeId)
|
||||
}
|
||||
ptyAgentForegroundContextPaths.set(
|
||||
id,
|
||||
getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId })
|
||||
@@ -1306,6 +1311,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
id,
|
||||
cwd: ptyInitialCwd.get(id) ?? '',
|
||||
title: proc.process || ptyShellName.get(id) || 'shell',
|
||||
...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}),
|
||||
...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -53,6 +53,14 @@ export class DeviceRegistry {
|
||||
}
|
||||
|
||||
addDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
|
||||
return this.createAndPersistDevice(this.devices, name, scope)
|
||||
}
|
||||
|
||||
private createAndPersistDevice(
|
||||
existingDevices: DeviceEntry[],
|
||||
name: string,
|
||||
scope: DeviceScope
|
||||
): DeviceEntry {
|
||||
const entry: DeviceEntry = {
|
||||
deviceId: randomUUID(),
|
||||
name,
|
||||
@@ -61,8 +69,10 @@ export class DeviceRegistry {
|
||||
pairedAt: Date.now(),
|
||||
lastSeenAt: 0
|
||||
}
|
||||
this.devices.push(entry)
|
||||
this.save()
|
||||
const nextDevices = [...existingDevices, entry]
|
||||
// Why: a credential is not valid until its durable registry write succeeds.
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
return entry
|
||||
}
|
||||
|
||||
@@ -87,8 +97,8 @@ export class DeviceRegistry {
|
||||
// until a phone actually pairs, so users have no way to revoke a leaked
|
||||
// pre-pairing token.
|
||||
rotatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
|
||||
this.devices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope)
|
||||
return this.addDevice(name, scope)
|
||||
const retainedDevices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope)
|
||||
return this.createAndPersistDevice(retainedDevices, name, scope)
|
||||
}
|
||||
|
||||
removeDevice(deviceId: string): boolean {
|
||||
@@ -177,7 +187,7 @@ export class DeviceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
writeSecureJsonFile(this.registryPath, this.devices)
|
||||
private save(devices: DeviceEntry[] = this.devices): void {
|
||||
writeSecureJsonFile(this.registryPath, devices)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PtyProcessInfo } from '../providers/types'
|
||||
import type { RuntimeTerminalCreate } from '../../shared/runtime-types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity'
|
||||
import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency'
|
||||
|
||||
type CreateRun = (
|
||||
canonicalWorktreeSelector: string | undefined,
|
||||
preAllocatedHandle: string | undefined
|
||||
) => Promise<RuntimeTerminalCreate>
|
||||
|
||||
function createRuntimeForDedupe(listProcesses = vi.fn(async (): Promise<PtyProcessInfo[]> => [])) {
|
||||
const handleByPtyId = new Map<string, string>()
|
||||
const runtime = Object.create(OrcaRuntimeService.prototype) as OrcaRuntimeService
|
||||
Object.assign(runtime, {
|
||||
terminalCreateIdempotency: new RemoteRuntimeTerminalCreateIdempotency(),
|
||||
ptyController: { listProcesses },
|
||||
resolveTerminalWorkspaceLaunchScope: vi.fn(async (selector: string) => ({
|
||||
id: selector.startsWith('id:') ? selector.slice(3) : selector
|
||||
})),
|
||||
adoptControllerTerminalHandle: vi.fn((ptyId: string, handle: string) => {
|
||||
handleByPtyId.set(ptyId, handle)
|
||||
}),
|
||||
recordPtyWorktree: vi.fn((ptyId: string, worktreeId: string, state: { title?: string }) => ({
|
||||
ptyId,
|
||||
worktreeId,
|
||||
title: state.title ?? null
|
||||
})),
|
||||
issuePtyHandle: vi.fn((pty: { ptyId: string }) => handleByPtyId.get(pty.ptyId))
|
||||
})
|
||||
return { runtime, listProcesses }
|
||||
}
|
||||
|
||||
function createdTerminal(handle: string, worktreeId = 'worktree-1'): RuntimeTerminalCreate {
|
||||
return { handle, worktreeId, title: null }
|
||||
}
|
||||
|
||||
describe('terminal create idempotency', () => {
|
||||
it('derives a stable handle isolated by authenticated client and canonical worktree', () => {
|
||||
const first = deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1')
|
||||
|
||||
expect(deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1')).toBe(
|
||||
first
|
||||
)
|
||||
expect(
|
||||
deriveRemoteRuntimeTerminalCreateHandle('device-b', 'worktree-1', 'mutation-1')
|
||||
).not.toBe(first)
|
||||
expect(
|
||||
deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-2', 'mutation-1')
|
||||
).not.toBe(first)
|
||||
expect(first).toMatch(/^term_[0-9a-f]{32}$/)
|
||||
})
|
||||
|
||||
it('shares an in-flight create without scanning inventory on the initial request', async () => {
|
||||
const { runtime, listProcesses } = createRuntimeForDedupe()
|
||||
let resolveCreate: (value: RuntimeTerminalCreate) => void = () => {}
|
||||
const pending = new Promise<RuntimeTerminalCreate>((resolve) => {
|
||||
resolveCreate = resolve
|
||||
})
|
||||
const create = vi.fn<CreateRun>(() => pending)
|
||||
|
||||
const first = runtime.dedupeTerminalCreate(
|
||||
'device-a',
|
||||
'id:worktree-1',
|
||||
'mutation-1',
|
||||
false,
|
||||
create
|
||||
)
|
||||
const retry = runtime.dedupeTerminalCreate(
|
||||
'device-a',
|
||||
'worktree-1',
|
||||
'mutation-1',
|
||||
false,
|
||||
create
|
||||
)
|
||||
await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1))
|
||||
const stableHandle = create.mock.calls[0][1]
|
||||
resolveCreate(createdTerminal(stableHandle ?? 'missing'))
|
||||
|
||||
await expect(Promise.all([first, retry])).resolves.toEqual([
|
||||
createdTerminal(stableHandle ?? 'missing'),
|
||||
createdTerminal(stableHandle ?? 'missing')
|
||||
])
|
||||
expect(listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adopts the original PTY after a runtime-process restart without rerunning startup', async () => {
|
||||
const liveSessions: PtyProcessInfo[] = []
|
||||
const firstRuntime = createRuntimeForDedupe(vi.fn(async () => liveSessions)).runtime
|
||||
const secondInventory = vi.fn(async () => liveSessions)
|
||||
const secondRuntime = createRuntimeForDedupe(secondInventory).runtime
|
||||
const startup = vi.fn<CreateRun>(async (_selector, handle) => {
|
||||
liveSessions.push({
|
||||
id: 'worktree-1@@session-a',
|
||||
cwd: '/workspace',
|
||||
title: 'pwsh',
|
||||
worktreeId: 'worktree-1',
|
||||
terminalHandle: handle
|
||||
})
|
||||
return createdTerminal(handle ?? 'missing')
|
||||
})
|
||||
|
||||
const first = await firstRuntime.dedupeTerminalCreate(
|
||||
'device-a',
|
||||
'id:worktree-1',
|
||||
'mutation-1',
|
||||
false,
|
||||
startup
|
||||
)
|
||||
const retrySpawn = vi.fn<CreateRun>()
|
||||
const recovered = await secondRuntime.dedupeTerminalCreate(
|
||||
'device-a',
|
||||
'worktree-1',
|
||||
'mutation-1',
|
||||
true,
|
||||
retrySpawn
|
||||
)
|
||||
|
||||
expect(recovered.handle).toBe(first.handle)
|
||||
expect(recovered.ptyId).toBe('worktree-1@@session-a')
|
||||
expect(startup).toHaveBeenCalledTimes(1)
|
||||
expect(retrySpawn).not.toHaveBeenCalled()
|
||||
expect(secondInventory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('creates with the same stable handle after authoritative inventory proves absence', async () => {
|
||||
const { runtime, listProcesses } = createRuntimeForDedupe()
|
||||
const create = vi.fn<CreateRun>(async (_selector, handle) =>
|
||||
createdTerminal(handle ?? 'missing')
|
||||
)
|
||||
|
||||
const result = await runtime.dedupeTerminalCreate(
|
||||
'device-a',
|
||||
'id:worktree-1',
|
||||
'mutation-1',
|
||||
true,
|
||||
create
|
||||
)
|
||||
|
||||
expect(listProcesses).toHaveBeenCalledTimes(1)
|
||||
expect(create).toHaveBeenCalledWith('id:worktree-1', result.handle)
|
||||
})
|
||||
|
||||
it('fails safely without spawning when retry inventory is unavailable', async () => {
|
||||
const listProcesses = vi.fn(async (): Promise<PtyProcessInfo[]> => {
|
||||
throw new Error('controller offline')
|
||||
})
|
||||
const { runtime } = createRuntimeForDedupe(listProcesses)
|
||||
const create = vi.fn<CreateRun>()
|
||||
|
||||
await expect(
|
||||
runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create)
|
||||
).rejects.toThrow('runtime_unavailable')
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails safely when an older provider omits identity for a live same-worktree PTY', async () => {
|
||||
const { runtime } = createRuntimeForDedupe(
|
||||
vi.fn(async () => [
|
||||
{
|
||||
id: 'worktree-1@@legacy-session',
|
||||
cwd: '/workspace',
|
||||
title: 'shell',
|
||||
worktreeId: 'worktree-1'
|
||||
}
|
||||
])
|
||||
)
|
||||
const create = vi.fn<CreateRun>()
|
||||
|
||||
await expect(
|
||||
runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create)
|
||||
).rejects.toThrow('runtime_unavailable')
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adopts an exact identity even when another legacy PTY lacks metadata', async () => {
|
||||
const handle = deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1')
|
||||
const { runtime } = createRuntimeForDedupe(
|
||||
vi.fn(async () => [
|
||||
{
|
||||
id: 'worktree-1@@legacy-session',
|
||||
cwd: '/workspace',
|
||||
title: 'shell',
|
||||
worktreeId: 'worktree-1'
|
||||
},
|
||||
{
|
||||
id: 'worktree-1@@created-session',
|
||||
cwd: '/workspace',
|
||||
title: 'pwsh',
|
||||
worktreeId: 'worktree-1',
|
||||
terminalHandle: handle
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.dedupeTerminalCreate(
|
||||
'device-a',
|
||||
'id:worktree-1',
|
||||
'mutation-1',
|
||||
true,
|
||||
vi.fn<CreateRun>()
|
||||
)
|
||||
).resolves.toMatchObject({ handle, ptyId: 'worktree-1@@created-session' })
|
||||
})
|
||||
|
||||
it('fails closed when a matching handle belongs to another worktree', async () => {
|
||||
const handle = deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1')
|
||||
const { runtime } = createRuntimeForDedupe(
|
||||
vi.fn(async () => [
|
||||
{
|
||||
id: 'worktree-2@@session-a',
|
||||
cwd: '/other',
|
||||
title: 'shell',
|
||||
worktreeId: 'worktree-2',
|
||||
terminalHandle: handle
|
||||
}
|
||||
])
|
||||
)
|
||||
const create = vi.fn<CreateRun>()
|
||||
|
||||
await expect(
|
||||
runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create)
|
||||
).rejects.toThrow('terminal_create_identity_conflict')
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds concurrent creates and releases capacity after settlement', async () => {
|
||||
const idempotency = new RemoteRuntimeTerminalCreateIdempotency(1)
|
||||
let resolveFirst: (value: RuntimeTerminalCreate) => void = () => {}
|
||||
const first = idempotency.run(
|
||||
'device-a',
|
||||
'worktree-1',
|
||||
'mutation-1',
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
idempotency.run('device-a', 'worktree-1', 'mutation-2', async () =>
|
||||
createdTerminal('terminal-2')
|
||||
)
|
||||
).rejects.toThrow('Too many terminal creations')
|
||||
resolveFirst(createdTerminal('terminal-1'))
|
||||
await first
|
||||
await expect(
|
||||
idempotency.run('device-a', 'worktree-1', 'mutation-2', async () =>
|
||||
createdTerminal('terminal-2')
|
||||
)
|
||||
).resolves.toEqual(createdTerminal('terminal-2'))
|
||||
})
|
||||
})
|
||||
@@ -375,6 +375,8 @@ import type {
|
||||
} from '../../shared/runtime-types'
|
||||
import type { AutomationService } from '../automations/service'
|
||||
import { RuntimeBrowserCommands } from './orca-runtime-browser'
|
||||
import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency'
|
||||
import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity'
|
||||
import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout'
|
||||
import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer'
|
||||
import {
|
||||
@@ -1116,6 +1118,7 @@ type TerminalCreateOptions = {
|
||||
leafId?: string
|
||||
sessionId?: string
|
||||
persistHostSessionBinding?: boolean
|
||||
preAllocatedHandle?: string
|
||||
// Why: the headless mobile-session create publishes its own authoritative
|
||||
// snapshot (with the correct target group) right after spawn. Skip the
|
||||
// intermediate pty-backed publish so the new tab doesn't briefly flash in
|
||||
@@ -2295,6 +2298,7 @@ export class OrcaRuntimeService {
|
||||
string,
|
||||
Promise<RuntimeMobileSessionCreateTerminalResult>
|
||||
>()
|
||||
private readonly terminalCreateIdempotency = new RemoteRuntimeTerminalCreateIdempotency()
|
||||
// Why: idempotency map for worktree.create — a create interrupted by a mobile
|
||||
// connection migration is retried with the same clientMutationId and returns
|
||||
// the in-flight (or just-finished) operation instead of a duplicate worktree.
|
||||
@@ -19530,7 +19534,8 @@ export class OrcaRuntimeService {
|
||||
const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts)
|
||||
const cwd =
|
||||
this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path
|
||||
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
|
||||
const preAllocatedHandle =
|
||||
launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle()
|
||||
// Why: mint tabId in main before spawn so paneKey is known at PTY env
|
||||
// build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys
|
||||
// off `${tabId}:${leafId}` — without these vars set on the PTY, the
|
||||
@@ -19804,6 +19809,100 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async dedupeTerminalCreate(
|
||||
clientIdentity: string,
|
||||
worktreeSelector: string | undefined,
|
||||
clientMutationId: string | undefined,
|
||||
reconcileExisting: boolean,
|
||||
run: (
|
||||
canonicalWorktreeSelector: string | undefined,
|
||||
preAllocatedHandle: string | undefined
|
||||
) => Promise<RuntimeTerminalCreate>
|
||||
): Promise<RuntimeTerminalCreate> {
|
||||
if (!clientMutationId || !worktreeSelector) {
|
||||
if (reconcileExisting) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return await run(worktreeSelector, undefined)
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
|
||||
const canonicalWorktreeSelector = `id:${workspace.id}`
|
||||
const preAllocatedHandle = deriveRemoteRuntimeTerminalCreateHandle(
|
||||
clientIdentity,
|
||||
workspace.id,
|
||||
clientMutationId
|
||||
)
|
||||
return this.terminalCreateIdempotency.run(
|
||||
clientIdentity,
|
||||
workspace.id,
|
||||
clientMutationId,
|
||||
async () => {
|
||||
if (reconcileExisting) {
|
||||
const adopted = await this.reconcileRemoteTerminalCreate(workspace.id, preAllocatedHandle)
|
||||
if (adopted) {
|
||||
return adopted
|
||||
}
|
||||
}
|
||||
return await run(canonicalWorktreeSelector, preAllocatedHandle)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private async reconcileRemoteTerminalCreate(
|
||||
worktreeId: string,
|
||||
terminalHandle: string
|
||||
): Promise<RuntimeTerminalCreate | null> {
|
||||
if (!this.ptyController?.listProcesses) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const listed = await withTimeoutResult(
|
||||
this.ptyController.listProcesses(),
|
||||
PTY_CONTROLLER_LIST_TIMEOUT_MS
|
||||
)
|
||||
if (!listed.ok) {
|
||||
// Why: unknown inventory cannot prove the first create failed, so spawning could duplicate a live shell.
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const matches = listed.value.filter((session) => session.terminalHandle === terminalHandle)
|
||||
if (matches.length > 1) {
|
||||
throw new Error('terminal_create_identity_conflict')
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
const sameWorktreeHasUnknownIdentity = listed.value.some(
|
||||
(session) =>
|
||||
(session.worktreeId ?? inferWorktreeIdFromPtyId(session.id)) === worktreeId &&
|
||||
!session.terminalHandle
|
||||
)
|
||||
if (sameWorktreeHasUnknownIdentity) {
|
||||
// Why: older retained providers may list the first shell without its handle; absence is not authoritative in that shape.
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return null
|
||||
}
|
||||
const session = matches[0]
|
||||
const authoritativeWorktreeId = session.worktreeId ?? inferWorktreeIdFromPtyId(session.id)
|
||||
if (authoritativeWorktreeId !== worktreeId) {
|
||||
// Why: a reused address or forged provider record must never adopt a PTY from another workspace.
|
||||
throw new Error('terminal_create_identity_conflict')
|
||||
}
|
||||
this.adoptControllerTerminalHandle(session.id, terminalHandle)
|
||||
const pty = this.recordPtyWorktree(session.id, worktreeId, {
|
||||
connected: true,
|
||||
title: session.title
|
||||
})
|
||||
const adoptedHandle = this.issuePtyHandle(pty)
|
||||
if (adoptedHandle !== terminalHandle) {
|
||||
throw new Error('terminal_create_identity_conflict')
|
||||
}
|
||||
return {
|
||||
handle: adoptedHandle,
|
||||
ptyId: session.id,
|
||||
worktreeId,
|
||||
title: session.title || null,
|
||||
surface: 'background'
|
||||
}
|
||||
}
|
||||
|
||||
async launchAgentTerminal(
|
||||
worktreeSelector: string,
|
||||
opts: { agent: TuiAgent; prompt: string; title?: string }
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveAdvertisedPairingEndpoint } from './pairing-endpoint'
|
||||
|
||||
describe('resolveAdvertisedPairingEndpoint', () => {
|
||||
const bound = 'ws://0.0.0.0:6768'
|
||||
|
||||
it('uses loopback by default without advertising the wildcard bind address', () => {
|
||||
expect(resolveAdvertisedPairingEndpoint(bound, null)).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://127.0.0.1:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['100.64.1.20', 'ws://100.64.1.20:6768'],
|
||||
['host.tailnet.ts.net', 'ws://host.tailnet.ts.net:6768'],
|
||||
['proxy.example.test:80', 'ws://proxy.example.test'],
|
||||
['lan-host:7443', 'ws://lan-host:7443'],
|
||||
['::1', 'ws://[::1]:6768'],
|
||||
['2001:db8::0', 'ws://[2001:db8::]:6768'],
|
||||
['[2001:db8::4]:7443', 'ws://[2001:db8::4]:7443'],
|
||||
['http://proxy.example.test/orca', 'ws://proxy.example.test/orca'],
|
||||
['https://proxy.example.test/orca', 'wss://proxy.example.test/orca'],
|
||||
[
|
||||
'wss://proxy.example.test:8443/orca?route=runtime',
|
||||
'wss://proxy.example.test:8443/orca?route=runtime'
|
||||
]
|
||||
])('normalizes %s', (input, expected) => {
|
||||
expect(resolveAdvertisedPairingEndpoint(bound, input)).toEqual({
|
||||
ok: true,
|
||||
endpoint: expected
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'*',
|
||||
'0.0.0.0',
|
||||
'::',
|
||||
'ftp://proxy.example.test',
|
||||
'ws://user:secret@proxy.example.test',
|
||||
'ws://proxy.example.test/#fragment',
|
||||
'host.example.test/path',
|
||||
'host.example.test:0',
|
||||
'host.example.test:',
|
||||
'[::1]:',
|
||||
'host.example.test:65536'
|
||||
])('rejects unusable advertised endpoint %s', (input) => {
|
||||
expect(resolveAdvertisedPairingEndpoint(bound, input)).toMatchObject({
|
||||
ok: false,
|
||||
reason: 'invalid_advertised_endpoint'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { isIP } from 'node:net'
|
||||
|
||||
export const INVALID_PAIRING_ENDPOINT_GUIDANCE =
|
||||
'Use a reachable hostname, host:port, IPv4/IPv6 literal, or ws(s):// URL. HTTP(S) URLs are normalized to WebSocket URLs.'
|
||||
|
||||
export type PairingEndpointResolution =
|
||||
| { ok: true; endpoint: string }
|
||||
| {
|
||||
ok: false
|
||||
reason: 'invalid_advertised_endpoint'
|
||||
guidance: string
|
||||
}
|
||||
|
||||
export function resolveAdvertisedPairingEndpoint(
|
||||
boundEndpoint: string,
|
||||
advertisedAddress: string | null | undefined
|
||||
): PairingEndpointResolution {
|
||||
const endpoint = new URL(boundEndpoint)
|
||||
const override = advertisedAddress?.trim()
|
||||
if (!override) {
|
||||
// Why: a wildcard listener is not client-reachable; default pairing must remain local-only unless explicitly advertised.
|
||||
endpoint.hostname = '127.0.0.1'
|
||||
return valid(formatWebSocketUrl(endpoint))
|
||||
}
|
||||
|
||||
if (override.includes('://')) {
|
||||
return resolveFullUrl(override)
|
||||
}
|
||||
|
||||
const parsed = parseHostOverride(override)
|
||||
if (!parsed || isWildcardHost(parsed.hostname)) {
|
||||
return invalid()
|
||||
}
|
||||
endpoint.hostname = bracketIpv6(parsed.hostname)
|
||||
if (parsed.port) {
|
||||
endpoint.port = parsed.port
|
||||
}
|
||||
return valid(formatWebSocketUrl(endpoint))
|
||||
}
|
||||
|
||||
function resolveFullUrl(value: string): PairingEndpointResolution {
|
||||
let endpoint: URL
|
||||
try {
|
||||
endpoint = new URL(value)
|
||||
} catch {
|
||||
return invalid()
|
||||
}
|
||||
if (endpoint.protocol === 'http:') {
|
||||
endpoint.protocol = 'ws:'
|
||||
} else if (endpoint.protocol === 'https:') {
|
||||
endpoint.protocol = 'wss:'
|
||||
}
|
||||
if (
|
||||
(endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') ||
|
||||
!endpoint.hostname ||
|
||||
endpoint.username ||
|
||||
endpoint.password ||
|
||||
endpoint.hash ||
|
||||
endpoint.port === '0' ||
|
||||
isWildcardHost(endpoint.hostname)
|
||||
) {
|
||||
return invalid()
|
||||
}
|
||||
return valid(formatWebSocketUrl(endpoint))
|
||||
}
|
||||
|
||||
function parseHostOverride(value: string): { hostname: string; port: string } | null {
|
||||
try {
|
||||
const rawIpVersion = isIP(value)
|
||||
const explicitPort = rawIpVersion === 6 ? null : getExplicitPort(value)
|
||||
// Why: port zero is a bind-time request, not an endpoint a remote client can dial.
|
||||
if (explicitPort === '0') {
|
||||
return null
|
||||
}
|
||||
if (rawIpVersion !== 6 && value.endsWith(':')) {
|
||||
return null
|
||||
}
|
||||
const url = new URL(rawIpVersion === 6 ? `ws://[${value}]` : `ws://${value}`)
|
||||
if (
|
||||
!url.hostname ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.pathname !== '/' ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { hostname: unbracketIpv6(url.hostname), port: explicitPort ?? url.port }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getExplicitPort(value: string): string | null {
|
||||
const match = value.startsWith('[') ? value.match(/^\[[^\]]+\]:(\d+)$/) : value.match(/:(\d+)$/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function isWildcardHost(hostname: string): boolean {
|
||||
const normalized = unbracketIpv6(hostname).toLowerCase()
|
||||
// Why: wildcard bind addresses identify local interfaces, not a route a client can connect to.
|
||||
return normalized === '*' || normalized === '0.0.0.0' || normalized === '::'
|
||||
}
|
||||
|
||||
function bracketIpv6(hostname: string): string {
|
||||
const normalized = unbracketIpv6(hostname)
|
||||
return normalized.includes(':') ? `[${normalized}]` : normalized
|
||||
}
|
||||
|
||||
function unbracketIpv6(hostname: string): string {
|
||||
return hostname.replace(/^\[|\]$/g, '')
|
||||
}
|
||||
|
||||
function formatWebSocketUrl(url: URL): string {
|
||||
const formatted = url.toString()
|
||||
return url.pathname === '/' && !url.search && !url.hash ? formatted.replace(/\/$/, '') : formatted
|
||||
}
|
||||
|
||||
function valid(endpoint: string): PairingEndpointResolution {
|
||||
return { ok: true, endpoint }
|
||||
}
|
||||
|
||||
function invalid(): PairingEndpointResolution {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid_advertised_endpoint',
|
||||
guidance: INVALID_PAIRING_ENDPOINT_GUIDANCE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RuntimeTerminalCreate } from '../../shared/runtime-types'
|
||||
|
||||
const DEFAULT_MAX_IN_FLIGHT_TERMINAL_CREATES = 4_096
|
||||
|
||||
export class RemoteRuntimeTerminalCreateIdempotency {
|
||||
private readonly inFlight = new Map<string, Promise<RuntimeTerminalCreate>>()
|
||||
|
||||
constructor(private readonly maxInFlight = DEFAULT_MAX_IN_FLIGHT_TERMINAL_CREATES) {}
|
||||
|
||||
run(
|
||||
clientIdentity: string,
|
||||
worktreeId: string,
|
||||
clientMutationId: string,
|
||||
create: () => Promise<RuntimeTerminalCreate>
|
||||
): Promise<RuntimeTerminalCreate> {
|
||||
const key = `${clientIdentity}\0${worktreeId}\0${clientMutationId}`
|
||||
const existing = this.inFlight.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
if (this.inFlight.size >= this.maxInFlight) {
|
||||
return Promise.reject(
|
||||
new Error('Too many terminal creations are still pending; retry after they settle.')
|
||||
)
|
||||
}
|
||||
|
||||
const promise = create()
|
||||
this.inFlight.set(key, promise)
|
||||
const drop = (): void => {
|
||||
if (this.inFlight.get(key) === promise) {
|
||||
this.inFlight.delete(key)
|
||||
}
|
||||
}
|
||||
void promise.then(drop, drop)
|
||||
return promise
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export function deriveRemoteRuntimeTerminalCreateHandle(
|
||||
clientIdentity: string,
|
||||
worktreeId: string,
|
||||
clientMutationId: string
|
||||
): string {
|
||||
const digest = createHash('sha256')
|
||||
.update('orca.remote-terminal-create.v2\0')
|
||||
.update(clientIdentity)
|
||||
.update('\0')
|
||||
.update(worktreeId)
|
||||
.update('\0')
|
||||
.update(clientMutationId)
|
||||
.digest('hex')
|
||||
return `term_${digest.slice(0, 32)}`
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcContext } from '../core'
|
||||
import { TERMINAL_METHODS } from './terminal'
|
||||
|
||||
describe('terminal.create RPC idempotency', () => {
|
||||
it('scopes the mutation key to the authenticated paired device and worktree', async () => {
|
||||
const terminal = { handle: 'terminal-1', worktreeId: 'worktree-1', title: null }
|
||||
const createTerminal = vi.fn(async () => terminal)
|
||||
const dedupeTerminalCreate = vi.fn(
|
||||
async (
|
||||
_clientIdentity: string,
|
||||
_worktree: string | undefined,
|
||||
_mutationId: string | undefined,
|
||||
_reconcileExisting: boolean,
|
||||
run: (worktree: string | undefined, handle: string | undefined) => Promise<typeof terminal>
|
||||
) => run('id:worktree-1', 'term_stable')
|
||||
)
|
||||
const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create')
|
||||
if (!method) {
|
||||
throw new Error('terminal.create method missing')
|
||||
}
|
||||
|
||||
const result = await method.handler(
|
||||
{
|
||||
worktree: 'id:worktree-1',
|
||||
clientMutationId: 'mutation-1',
|
||||
command: 'pwsh',
|
||||
resumeProviderSession: {
|
||||
key: 'session_id',
|
||||
id: 'session-1',
|
||||
transcriptPath: 'C:\\Users\\example\\.codex\\sessions\\rollout.jsonl'
|
||||
}
|
||||
},
|
||||
{
|
||||
runtime: { createTerminal, dedupeTerminalCreate },
|
||||
pairedDeviceId: 'device-a',
|
||||
clientId: 'bearer-token'
|
||||
} as unknown as RpcContext,
|
||||
vi.fn()
|
||||
)
|
||||
|
||||
expect(dedupeTerminalCreate).toHaveBeenCalledWith(
|
||||
'device-a',
|
||||
'id:worktree-1',
|
||||
'mutation-1',
|
||||
false,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(createTerminal).toHaveBeenCalledWith(
|
||||
'id:worktree-1',
|
||||
expect.objectContaining({
|
||||
command: 'pwsh',
|
||||
preAllocatedHandle: 'term_stable',
|
||||
resumeProviderSession: {
|
||||
key: 'session_id',
|
||||
id: 'session-1',
|
||||
transcriptPath: 'C:\\Users\\example\\.codex\\sessions\\rollout.jsonl'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({ terminal })
|
||||
})
|
||||
})
|
||||
@@ -910,6 +910,8 @@ const TerminalWait = TerminalHandle.extend({
|
||||
|
||||
const TerminalCreateParams = z.object({
|
||||
worktree: OptionalString,
|
||||
clientMutationId: z.string().min(1).max(128).optional(),
|
||||
reconcileExisting: z.boolean().optional(),
|
||||
command: OptionalString,
|
||||
startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
@@ -1362,29 +1364,37 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'terminal.create',
|
||||
params: TerminalCreateParams,
|
||||
handler: async (params, { runtime }) => ({
|
||||
terminal: await runtime.createTerminal(params.worktree, {
|
||||
command: params.command,
|
||||
startupCommandDelivery: params.startupCommandDelivery,
|
||||
env: params.env,
|
||||
envToDelete: params.envToDelete,
|
||||
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
|
||||
...(params.resumeProviderSession
|
||||
? { resumeProviderSession: params.resumeProviderSession }
|
||||
: {}),
|
||||
...(params.launchToken ? { launchToken: params.launchToken } : {}),
|
||||
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
|
||||
...(params.terminalColorQueryReplies
|
||||
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
|
||||
: {}),
|
||||
title: params.title,
|
||||
focus: params.focus === true,
|
||||
rendererBacked: params.rendererBacked === true,
|
||||
activate: params.activate === true,
|
||||
presentation: params.presentation,
|
||||
tabId: params.tabId,
|
||||
leafId: params.leafId
|
||||
})
|
||||
handler: async (params, { runtime, pairedDeviceId, clientId }) => ({
|
||||
terminal: await runtime.dedupeTerminalCreate(
|
||||
pairedDeviceId ?? clientId ?? 'local',
|
||||
params.worktree,
|
||||
params.clientMutationId,
|
||||
params.reconcileExisting === true,
|
||||
(canonicalWorktreeSelector, preAllocatedHandle) =>
|
||||
runtime.createTerminal(canonicalWorktreeSelector, {
|
||||
command: params.command,
|
||||
startupCommandDelivery: params.startupCommandDelivery,
|
||||
env: params.env,
|
||||
envToDelete: params.envToDelete,
|
||||
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
|
||||
...(params.resumeProviderSession
|
||||
? { resumeProviderSession: params.resumeProviderSession }
|
||||
: {}),
|
||||
...(params.launchToken ? { launchToken: params.launchToken } : {}),
|
||||
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
|
||||
...(params.terminalColorQueryReplies
|
||||
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
|
||||
: {}),
|
||||
title: params.title,
|
||||
focus: params.focus === true,
|
||||
rendererBacked: params.rendererBacked === true,
|
||||
activate: params.activate === true,
|
||||
presentation: params.presentation,
|
||||
tabId: params.tabId,
|
||||
leafId: params.leafId,
|
||||
...(preAllocatedHandle ? { preAllocatedHandle } : {})
|
||||
})
|
||||
)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WebSocket } from 'ws'
|
||||
import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('RemoteRuntimeServerHeartbeat', () => {
|
||||
it('grants clients a fresh probe after the server event loop resumes', async () => {
|
||||
vi.useFakeTimers()
|
||||
let now = 1_000
|
||||
const socket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket
|
||||
const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now)
|
||||
heartbeat.noteAlive(socket)
|
||||
heartbeat.start(() => [socket])
|
||||
|
||||
now += 100
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
now += 3_600_000
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(socket.ping).toHaveBeenCalledTimes(2)
|
||||
expect(socket.terminate).not.toHaveBeenCalled()
|
||||
|
||||
now += 100
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(socket.terminate).toHaveBeenCalledTimes(1)
|
||||
heartbeat.stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { WebSocket } from 'ws'
|
||||
|
||||
export class RemoteRuntimeServerHeartbeat {
|
||||
private timer: ReturnType<typeof setInterval> | null = null
|
||||
private lastTickAt: number | null = null
|
||||
private readonly alive = new WeakSet<WebSocket>()
|
||||
|
||||
constructor(
|
||||
private readonly intervalMs: number,
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly warningClientCount = 128
|
||||
) {}
|
||||
|
||||
noteAlive(socket: WebSocket): void {
|
||||
this.alive.add(socket)
|
||||
}
|
||||
|
||||
start(getClients: () => Iterable<WebSocket>): void {
|
||||
if (this.timer) {
|
||||
return
|
||||
}
|
||||
this.lastTickAt = this.now()
|
||||
this.timer = setInterval(() => this.sweep(getClients()), this.intervalMs)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
this.lastTickAt = null
|
||||
}
|
||||
|
||||
private sweep(clients: Iterable<WebSocket>): void {
|
||||
const tickAt = this.now()
|
||||
const elapsedMs = tickAt - (this.lastTickAt ?? tickAt)
|
||||
this.lastTickAt = tickAt
|
||||
const resumedFromPause = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5
|
||||
let reaped = 0
|
||||
let clientCount = 0
|
||||
for (const socket of clients) {
|
||||
clientCount += 1
|
||||
if (resumedFromPause) {
|
||||
// Why: a delayed server tick cannot infer that clients missed a probe they had no chance to answer.
|
||||
this.alive.add(socket)
|
||||
}
|
||||
if (!this.alive.has(socket)) {
|
||||
socket.terminate()
|
||||
reaped += 1
|
||||
continue
|
||||
}
|
||||
this.alive.delete(socket)
|
||||
try {
|
||||
socket.ping()
|
||||
} catch {
|
||||
// Why: a mid-teardown socket is finalized by its close/error listener.
|
||||
}
|
||||
}
|
||||
if (reaped > 0 || clientCount >= this.warningClientCount) {
|
||||
console.warn(`[ws-transport] heartbeat reaped ${reaped}; ${clientCount} tracked sockets`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createServer as createHttpServer, type Server as HttpServer } from 'nod
|
||||
import { WebSocketServer, type WebSocket } from 'ws'
|
||||
import type { RpcTransport } from './transport'
|
||||
import { createStaticWebClientHandler } from './static-web-client-handler'
|
||||
import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat'
|
||||
|
||||
const MAX_WS_MESSAGE_BYTES = 1024 * 1024
|
||||
// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs.
|
||||
@@ -30,6 +31,8 @@ export type WebSocketTransportOptions = {
|
||||
tlsKey?: string
|
||||
// Why: test-only override. Production uses HEARTBEAT_INTERVAL_MS.
|
||||
heartbeatIntervalMs?: number
|
||||
// Why: deterministic suspension tests must advance wall time independently from timer callbacks.
|
||||
heartbeatNow?: () => number
|
||||
// Why: test-only override. Production uses PRE_AUTH_TIMEOUT_MS.
|
||||
preAuthTimeoutMs?: number
|
||||
// Why: the pairing server can also serve the browser client, avoiding a second static server.
|
||||
@@ -45,16 +48,13 @@ export class WebSocketTransport implements RpcTransport {
|
||||
private readonly port: number
|
||||
private readonly tlsCert: string | undefined
|
||||
private readonly tlsKey: string | undefined
|
||||
private readonly heartbeatIntervalMs: number
|
||||
private readonly heartbeat: RemoteRuntimeServerHeartbeat
|
||||
private readonly preAuthTimeoutMs: number
|
||||
private readonly staticRoot: string | undefined
|
||||
private readonly fallbackPort: number | undefined
|
||||
private readonly preferPinnedPort: boolean
|
||||
private httpServer: HttpsServer | HttpServer | null = null
|
||||
private wss: WebSocketServer | null = null
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
// Why: a socket absent from this set at the next heartbeat sweep is presumed dead and terminated.
|
||||
private wsAlive = new WeakSet<WebSocket>()
|
||||
private messageHandler: WebSocketMessageHandler | null = null
|
||||
private connectionCloseHandler:
|
||||
| ((clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void)
|
||||
@@ -69,6 +69,7 @@ export class WebSocketTransport implements RpcTransport {
|
||||
tlsCert,
|
||||
tlsKey,
|
||||
heartbeatIntervalMs,
|
||||
heartbeatNow,
|
||||
preAuthTimeoutMs,
|
||||
staticRoot,
|
||||
fallbackPort,
|
||||
@@ -78,7 +79,11 @@ export class WebSocketTransport implements RpcTransport {
|
||||
this.port = port
|
||||
this.tlsCert = tlsCert
|
||||
this.tlsKey = tlsKey
|
||||
this.heartbeatIntervalMs = heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS
|
||||
this.heartbeat = new RemoteRuntimeServerHeartbeat(
|
||||
heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS,
|
||||
heartbeatNow,
|
||||
MAX_WS_CONNECTIONS
|
||||
)
|
||||
this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS
|
||||
this.staticRoot = staticRoot
|
||||
this.fallbackPort = fallbackPort
|
||||
@@ -194,7 +199,7 @@ export class WebSocketTransport implements RpcTransport {
|
||||
|
||||
this.httpServer = httpServer
|
||||
this.wss = wss
|
||||
this.startHeartbeat()
|
||||
this.heartbeat.start(() => this.wss?.clients ?? [])
|
||||
}
|
||||
|
||||
// Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing.
|
||||
@@ -206,56 +211,12 @@ export class WebSocketTransport implements RpcTransport {
|
||||
ws.once('close', () => clearTimeout(terminateTimer))
|
||||
}
|
||||
|
||||
// Why: the only reliable reaper of half-open mobile sockets stranded by background suspension without a TCP FIN.
|
||||
private startHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
return
|
||||
}
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
const wss = this.wss
|
||||
if (!wss) {
|
||||
return
|
||||
}
|
||||
let reaped = 0
|
||||
for (const ws of wss.clients) {
|
||||
if (!this.wsAlive.has(ws)) {
|
||||
// Why: terminate() frees the slot immediately; close() on a dead socket can hang for the OS-level TCP timeout.
|
||||
ws.terminate()
|
||||
reaped++
|
||||
continue
|
||||
}
|
||||
this.wsAlive.delete(ws)
|
||||
try {
|
||||
ws.ping()
|
||||
} catch {
|
||||
// Why: ping() can throw on a mid-teardown socket; the close handler runs regardless, so swallow it.
|
||||
}
|
||||
}
|
||||
// Why: steady reaping or riding the cap are early overload signals; stay quiet on healthy ticks.
|
||||
if (reaped > 0 || wss.clients.size >= MAX_WS_CONNECTIONS) {
|
||||
console.warn(
|
||||
`[ws-transport] heartbeat reaped ${reaped}; ${wss.clients.size} tracked sockets`
|
||||
)
|
||||
}
|
||||
}, this.heartbeatIntervalMs)
|
||||
if (typeof this.heartbeatTimer.unref === 'function') {
|
||||
this.heartbeatTimer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const wss = this.wss
|
||||
const httpServer = this.httpServer
|
||||
this.wss = null
|
||||
this.httpServer = null
|
||||
this.stopHeartbeat()
|
||||
this.heartbeat.stop()
|
||||
|
||||
if (wss) {
|
||||
for (const client of wss.clients) {
|
||||
@@ -282,11 +243,11 @@ export class WebSocketTransport implements RpcTransport {
|
||||
private handleConnection(ws: WebSocket): void {
|
||||
let finalized = false
|
||||
const onPong = (): void => {
|
||||
this.wsAlive.add(ws)
|
||||
this.heartbeat.noteAlive(ws)
|
||||
}
|
||||
const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => {
|
||||
// Why: any inbound frame counts as proof of life, so an actively-talking client isn't reaped mid-request.
|
||||
this.wsAlive.add(ws)
|
||||
this.heartbeat.noteAlive(ws)
|
||||
const msg =
|
||||
typeof data === 'string'
|
||||
? data
|
||||
@@ -338,7 +299,7 @@ export class WebSocketTransport implements RpcTransport {
|
||||
this.preAuthTimers.set(ws, preAuthTimer)
|
||||
|
||||
// Why: seed alive so the first heartbeat tick doesn't reap a fresh socket before its first pong.
|
||||
this.wsAlive.add(ws)
|
||||
this.heartbeat.noteAlive(ws)
|
||||
|
||||
ws.on('pong', onPong)
|
||||
ws.on('message', onMessage)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: this integration-style RPC test keeps the request/response contract together so regressions in the external CLI surface are easier to spot. */
|
||||
import { existsSync, mkdtempSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../../shared/terminal-stream-protocol'
|
||||
import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto'
|
||||
import { DeviceRegistry } from './device-registry'
|
||||
import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files'
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
@@ -368,6 +369,95 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('reports why pairing is unavailable before the WebSocket listener is ready', () => {
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath: mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')),
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
|
||||
expect(server.createPairingOffer({ name: 'Early test' })).toMatchObject({
|
||||
available: false,
|
||||
reason: 'websocket_unavailable',
|
||||
guidance: expect.any(String)
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an E2EE identity initialization failure after the local transport starts', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
mkdirSync(join(userDataPath, E2EE_KEYPAIR_FILENAME))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
await server.start()
|
||||
expect(server.createPairingOffer({ name: 'E2EE failure test' })).toMatchObject({
|
||||
available: false,
|
||||
reason: 'e2ee_key_unavailable',
|
||||
guidance: expect.any(String)
|
||||
})
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a registry persistence failure without retaining a ghost credential', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
await server.start()
|
||||
mkdirSync(join(userDataPath, DEVICE_REGISTRY_FILENAME))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
expect(server.createPairingOffer({ name: 'Registry failure test' })).toMatchObject({
|
||||
available: false,
|
||||
reason: 'device_registry_unavailable',
|
||||
guidance: expect.any(String)
|
||||
})
|
||||
expect(server.getDeviceRegistry()?.listDevices()).toHaveLength(0)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects wildcard advertised addresses before minting a device credential', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
expect(server.getDeviceRegistry()?.listDevices()).toHaveLength(0)
|
||||
expect(server.createPairingOffer({ address: '0.0.0.0', name: 'Invalid test' })).toMatchObject(
|
||||
{
|
||||
available: false,
|
||||
reason: 'invalid_advertised_endpoint',
|
||||
guidance: expect.any(String)
|
||||
}
|
||||
)
|
||||
expect(server.getDeviceRegistry()?.listDevices()).toHaveLength(0)
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('includes a web client URL when the web bundle is served by the runtime', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
|
||||
+168
-119
@@ -14,7 +14,7 @@ import { UnixSocketTransport } from './rpc/unix-socket-transport'
|
||||
import { WebSocketTransport } from './rpc/ws-transport'
|
||||
import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port-store'
|
||||
import type { WebSocket } from 'ws'
|
||||
import { DeviceRegistry, type DeviceScope } from './device-registry'
|
||||
import { DeviceRegistry, type DeviceEntry, type DeviceScope } from './device-registry'
|
||||
import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair'
|
||||
import { UnpairedDeviceAuthThrottle } from './rpc/unpaired-device-auth-throttle'
|
||||
import {
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
PairingProvisionRelayParams
|
||||
} from '../../shared/mobile-relay-credential-contract'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
|
||||
import { resolveAdvertisedPairingEndpoint } from './pairing-endpoint'
|
||||
import {
|
||||
decodeTerminalStreamFrame,
|
||||
type TerminalStreamFrame
|
||||
@@ -58,6 +59,34 @@ type OrcaRuntimeRpcServerOptions = {
|
||||
longPollCap?: number
|
||||
}
|
||||
|
||||
export type PairingOfferUnavailableReason =
|
||||
| 'websocket_unavailable'
|
||||
| 'device_registry_unavailable'
|
||||
| 'e2ee_key_unavailable'
|
||||
| 'invalid_advertised_endpoint'
|
||||
|
||||
export type PairingOfferUnavailable = {
|
||||
available: false
|
||||
reason: PairingOfferUnavailableReason
|
||||
guidance: string
|
||||
}
|
||||
|
||||
type PairingIdentityInitialization =
|
||||
| { ok: true; deviceRegistry: DeviceRegistry; e2eeKeypair: E2EEKeypair }
|
||||
| { ok: false; failure: PairingOfferUnavailable }
|
||||
|
||||
function pairingUnavailable(
|
||||
reason: PairingOfferUnavailableReason,
|
||||
guidance: string
|
||||
): PairingOfferUnavailable {
|
||||
return { available: false, reason, guidance }
|
||||
}
|
||||
|
||||
const DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE =
|
||||
'The pairing registry is unavailable. Verify that the Orca data directory is writable.'
|
||||
const E2EE_KEY_UNAVAILABLE_GUIDANCE =
|
||||
'The E2EE identity is unavailable. Verify that the Orca data directory is writable.'
|
||||
|
||||
type MobileRelayPairingProvider = {
|
||||
createPairingRelay(
|
||||
relayDeviceId: string
|
||||
@@ -86,43 +115,6 @@ const KEEPALIVE_INTERVAL_MS = 10_000
|
||||
// Why: cap long-polls at half the 32-slot connection budget so they can't starve short RPCs; overflow → runtime_busy. See §7 risk #2.
|
||||
const LONG_POLL_CAP = 16
|
||||
|
||||
function resolvePairingEndpoint(rawEndpoint: string, address: string | null | undefined): string {
|
||||
const endpoint = new URL(rawEndpoint)
|
||||
const override = address?.trim()
|
||||
if (!override) {
|
||||
endpoint.hostname = '127.0.0.1'
|
||||
return formatWebSocketUrl(endpoint)
|
||||
}
|
||||
if (/^wss?:\/\//i.test(override)) {
|
||||
return formatWebSocketUrl(new URL(override))
|
||||
}
|
||||
const parsed = parsePairingAddressOverride(override)
|
||||
endpoint.hostname = parsed.host.includes(':')
|
||||
? `[${parsed.host.replace(/^\[|\]$/g, '')}]`
|
||||
: parsed.host
|
||||
if (parsed.port) {
|
||||
endpoint.port = parsed.port
|
||||
}
|
||||
return formatWebSocketUrl(endpoint)
|
||||
}
|
||||
|
||||
function parsePairingAddressOverride(address: string): { host: string; port: string | null } {
|
||||
if (address.startsWith('[') || address.split(':').length === 2) {
|
||||
try {
|
||||
const parsed = new URL(`ws://${address}`)
|
||||
return { host: parsed.hostname.replace(/^\[|\]$/g, ''), port: parsed.port || null }
|
||||
} catch {
|
||||
return { host: address, port: null }
|
||||
}
|
||||
}
|
||||
return { host: address, port: null }
|
||||
}
|
||||
|
||||
function formatWebSocketUrl(url: URL): string {
|
||||
const formatted = url.toString()
|
||||
return url.pathname === '/' && !url.search && !url.hash ? formatted.replace(/\/$/, '') : formatted
|
||||
}
|
||||
|
||||
function createWebClientUrl(endpoint: string, pairingUrl: string): string {
|
||||
const url = new URL(endpoint)
|
||||
url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'
|
||||
@@ -437,6 +429,7 @@ export class OrcaRuntimeRpcServer {
|
||||
private readonly relayRevokeOutbox: RelayRevokeOutbox
|
||||
private deviceRegistry: DeviceRegistry | null = null
|
||||
private e2eeKeypair: E2EEKeypair | null = null
|
||||
private pairingInitializationFailure: PairingOfferUnavailable | null = null
|
||||
private tlsFingerprint: string | null = null
|
||||
private activeTransports: RpcTransport[] = []
|
||||
private transports: RuntimeTransportMetadata[] = []
|
||||
@@ -575,7 +568,7 @@ export class OrcaRuntimeRpcServer {
|
||||
rotate?: boolean
|
||||
scope?: DeviceScope
|
||||
}):
|
||||
| { available: false }
|
||||
| PairingOfferUnavailable
|
||||
| {
|
||||
available: true
|
||||
pairingUrl: string
|
||||
@@ -583,18 +576,40 @@ export class OrcaRuntimeRpcServer {
|
||||
deviceId: string
|
||||
webClientUrl: string | null
|
||||
} {
|
||||
if (this.pairingInitializationFailure) {
|
||||
return this.pairingInitializationFailure
|
||||
}
|
||||
const rawEndpoint = this.getWebSocketEndpoint()
|
||||
if (!rawEndpoint) {
|
||||
return pairingUnavailable(
|
||||
'websocket_unavailable',
|
||||
'WebSocket pairing is unavailable. Inspect preceding runtime errors and choose an unused --port if the listener failed.'
|
||||
)
|
||||
}
|
||||
if (!this.deviceRegistry) {
|
||||
return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE)
|
||||
}
|
||||
const publicKeyB64 = this.getE2EEPublicKey()
|
||||
if (!rawEndpoint || !this.deviceRegistry || !publicKeyB64) {
|
||||
return { available: false }
|
||||
if (!publicKeyB64) {
|
||||
return pairingUnavailable('e2ee_key_unavailable', E2EE_KEY_UNAVAILABLE_GUIDANCE)
|
||||
}
|
||||
|
||||
const endpoint = resolvePairingEndpoint(rawEndpoint, args.address)
|
||||
const advertised = resolveAdvertisedPairingEndpoint(rawEndpoint, args.address)
|
||||
if (!advertised.ok) {
|
||||
return pairingUnavailable(advertised.reason, advertised.guidance)
|
||||
}
|
||||
const endpoint = advertised.endpoint
|
||||
const deviceName = args.name ?? `CLI ${new Date().toLocaleDateString()}`
|
||||
const scope = args.scope ?? 'runtime'
|
||||
const device = args.rotate
|
||||
? this.deviceRegistry.rotatePendingDevice(deviceName, scope)
|
||||
: this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope)
|
||||
let device: DeviceEntry
|
||||
try {
|
||||
device = args.rotate
|
||||
? this.deviceRegistry.rotatePendingDevice(deviceName, scope)
|
||||
: this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope)
|
||||
} catch (error) {
|
||||
console.error('[runtime] Failed to persist pairing credential:', error)
|
||||
return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE)
|
||||
}
|
||||
const pairingUrl = encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint,
|
||||
@@ -618,7 +633,7 @@ export class OrcaRuntimeRpcServer {
|
||||
name?: string
|
||||
rotate?: boolean
|
||||
}): Promise<
|
||||
| { available: false }
|
||||
| PairingOfferUnavailable
|
||||
| {
|
||||
available: true
|
||||
pairingUrl: string
|
||||
@@ -781,6 +796,33 @@ export class OrcaRuntimeRpcServer {
|
||||
state.controllers.clear()
|
||||
}
|
||||
|
||||
private initializePairingIdentity(): PairingIdentityInitialization {
|
||||
let deviceRegistry: DeviceRegistry
|
||||
try {
|
||||
deviceRegistry = new DeviceRegistry(this.userDataPath)
|
||||
} catch (error) {
|
||||
console.error('[runtime] Failed to initialize pairing registry:', error)
|
||||
return {
|
||||
ok: false,
|
||||
failure: pairingUnavailable(
|
||||
'device_registry_unavailable',
|
||||
DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE
|
||||
)
|
||||
}
|
||||
}
|
||||
let e2eeKeypair: E2EEKeypair
|
||||
try {
|
||||
e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath)
|
||||
} catch (error) {
|
||||
console.error('[runtime] Failed to initialize E2EE identity:', error)
|
||||
return {
|
||||
ok: false,
|
||||
failure: pairingUnavailable('e2ee_key_unavailable', E2EE_KEY_UNAVAILABLE_GUIDANCE)
|
||||
}
|
||||
}
|
||||
return { ok: true, deviceRegistry, e2eeKeypair }
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.activeTransports.length > 0) {
|
||||
return
|
||||
@@ -833,82 +875,89 @@ export class OrcaRuntimeRpcServer {
|
||||
|
||||
// Why: WebSocket uses per-device tokens + E2EE (tweetnacl) instead of TLS since React Native can't pin self-signed certs.
|
||||
if (this.enableWebSocket) {
|
||||
try {
|
||||
this.deviceRegistry = new DeviceRegistry(this.userDataPath)
|
||||
this.e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath)
|
||||
const pairingIdentity = this.initializePairingIdentity()
|
||||
if (!pairingIdentity.ok) {
|
||||
this.deviceRegistry = null
|
||||
this.e2eeKeypair = null
|
||||
this.pairingInitializationFailure = pairingIdentity.failure
|
||||
} else {
|
||||
this.deviceRegistry = pairingIdentity.deviceRegistry
|
||||
this.e2eeKeypair = pairingIdentity.e2eeKeypair
|
||||
this.pairingInitializationFailure = null
|
||||
try {
|
||||
const wsTransport = new WebSocketTransport({
|
||||
host: '0.0.0.0',
|
||||
port: this.wsPort,
|
||||
staticRoot: this.webClientRoot,
|
||||
// Why: stable fallback port across restarts keeps paired devices' endpoints valid (STA-1511); wsPort 0 = random (E2E).
|
||||
...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}),
|
||||
...(this.preferPinnedWsPort ? { preferPinnedPort: true } : {})
|
||||
})
|
||||
// Why: session-scoped (recreated per start) so each desktop launch may notify once.
|
||||
this.unpairedDeviceAuthThrottle = new UnpairedDeviceAuthThrottle({
|
||||
onTrigger: () => this.onUnpairedDeviceAuthFailure?.()
|
||||
})
|
||||
const mobileSocketWiring = new MobileSocketWiring({
|
||||
deviceRegistry: pairingIdentity.deviceRegistry,
|
||||
e2eeKeypair: pairingIdentity.e2eeKeypair,
|
||||
onText: (socket, plaintext, reply, sendBinary) => {
|
||||
void this.handleWebSocketMessage(
|
||||
plaintext,
|
||||
reply,
|
||||
sendBinary,
|
||||
undefined,
|
||||
socket.ws,
|
||||
socket.device.deviceToken,
|
||||
socket
|
||||
)
|
||||
},
|
||||
onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws),
|
||||
onReady: () => {
|
||||
// Why: first authenticated mobile/remote client (direct WS and
|
||||
// cloud relay both attach here) starts path-candidate tracking.
|
||||
// Activation is a local-host concern: candidate buffers live on the
|
||||
// buffer-owning host's runtime, so a remote runtime proxy may
|
||||
// legitimately lack this method (its own server activates it).
|
||||
this.runtime.activateRecentPtyPathCandidateTracking?.()
|
||||
this.mobileRelayPairingProvider?.onDemandStateChanged?.()
|
||||
},
|
||||
onClose: (socket, hasOtherConnections) => {
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
this.abortWebSocketDispatches(socket.ws)
|
||||
// Why: subscriptions and binary streams are socket-scoped, but disconnect state is device-scoped across transports.
|
||||
this.runtime.cleanupSubscriptionsForConnection(socket.connectionId)
|
||||
this.runtime.cancelMobileDictationForConnection(socket.connectionId)
|
||||
this.binaryStreamHandlers.delete(socket.connectionId)
|
||||
if (!hasOtherConnections) {
|
||||
this.runtime.onClientDisconnected(socket.device.deviceToken)
|
||||
}
|
||||
},
|
||||
// Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing.
|
||||
onUnpairedDeviceAuthFailure: (metadata) => {
|
||||
if (metadata.transport === 'direct') {
|
||||
this.unpairedDeviceAuthThrottle?.recordFailure()
|
||||
}
|
||||
}
|
||||
})
|
||||
mobileSocketWiring.attachTransport(wsTransport)
|
||||
this.mobileSocketWiring = mobileSocketWiring
|
||||
|
||||
const wsTransport = new WebSocketTransport({
|
||||
host: '0.0.0.0',
|
||||
port: this.wsPort,
|
||||
staticRoot: this.webClientRoot,
|
||||
// Why: stable fallback port across restarts keeps paired devices' endpoints valid (STA-1511); wsPort 0 = random (E2E).
|
||||
...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}),
|
||||
...(this.preferPinnedWsPort ? { preferPinnedPort: true } : {})
|
||||
})
|
||||
// Why: session-scoped (recreated per start) so each desktop launch may notify once.
|
||||
this.unpairedDeviceAuthThrottle = new UnpairedDeviceAuthThrottle({
|
||||
onTrigger: () => this.onUnpairedDeviceAuthFailure?.()
|
||||
})
|
||||
const mobileSocketWiring = new MobileSocketWiring({
|
||||
deviceRegistry: this.deviceRegistry,
|
||||
e2eeKeypair: this.e2eeKeypair,
|
||||
onText: (socket, plaintext, reply, sendBinary) => {
|
||||
void this.handleWebSocketMessage(
|
||||
plaintext,
|
||||
reply,
|
||||
sendBinary,
|
||||
undefined,
|
||||
socket.ws,
|
||||
socket.device.deviceToken,
|
||||
socket
|
||||
)
|
||||
},
|
||||
onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws),
|
||||
onReady: () => {
|
||||
// Why: first authenticated mobile/remote client (direct WS and
|
||||
// cloud relay both attach here) starts path-candidate tracking.
|
||||
// Activation is a local-host concern: candidate buffers live on the
|
||||
// buffer-owning host's runtime, so a remote runtime proxy may
|
||||
// legitimately lack this method (its own server activates it).
|
||||
this.runtime.activateRecentPtyPathCandidateTracking?.()
|
||||
this.mobileRelayPairingProvider?.onDemandStateChanged?.()
|
||||
},
|
||||
onClose: (socket, hasOtherConnections) => {
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
this.abortWebSocketDispatches(socket.ws)
|
||||
// Why: subscriptions and binary streams are socket-scoped, but disconnect state is device-scoped across transports.
|
||||
this.runtime.cleanupSubscriptionsForConnection(socket.connectionId)
|
||||
this.runtime.cancelMobileDictationForConnection(socket.connectionId)
|
||||
this.binaryStreamHandlers.delete(socket.connectionId)
|
||||
if (!hasOtherConnections) {
|
||||
this.runtime.onClientDisconnected(socket.device.deviceToken)
|
||||
}
|
||||
},
|
||||
// Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing.
|
||||
onUnpairedDeviceAuthFailure: (metadata) => {
|
||||
if (metadata.transport === 'direct') {
|
||||
this.unpairedDeviceAuthThrottle?.recordFailure()
|
||||
}
|
||||
await wsTransport.start()
|
||||
if (this.wsPort !== 0 && wsTransport.resolvedPort !== this.wsPort) {
|
||||
writeWsFallbackPort(this.userDataPath, wsTransport.resolvedPort)
|
||||
}
|
||||
})
|
||||
mobileSocketWiring.attachTransport(wsTransport)
|
||||
this.mobileSocketWiring = mobileSocketWiring
|
||||
|
||||
await wsTransport.start()
|
||||
if (this.wsPort !== 0 && wsTransport.resolvedPort !== this.wsPort) {
|
||||
writeWsFallbackPort(this.userDataPath, wsTransport.resolvedPort)
|
||||
activeTransports.push(wsTransport)
|
||||
transportsMeta.push({
|
||||
kind: 'websocket',
|
||||
endpoint: `ws://0.0.0.0:${wsTransport.resolvedPort}`
|
||||
})
|
||||
} catch (error) {
|
||||
// Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only.
|
||||
console.error('[runtime] Failed to start WebSocket transport:', error)
|
||||
this.mobileSocketWiring = null
|
||||
}
|
||||
activeTransports.push(wsTransport)
|
||||
transportsMeta.push({
|
||||
kind: 'websocket',
|
||||
endpoint: `ws://0.0.0.0:${wsTransport.resolvedPort}`
|
||||
})
|
||||
} catch (error) {
|
||||
// Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only.
|
||||
console.error('[runtime] Failed to start WebSocket transport:', error)
|
||||
this.mobileSocketWiring = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
SERVE_UPDATE_HANDOFF_PATH_ENV,
|
||||
getServeUpdateHandoffPath,
|
||||
parseServeUpdateHandoffState
|
||||
} from '../shared/serve-update-handoff'
|
||||
|
||||
const { appMock, getCanonicalUserDataPathMock } = vi.hoisted(() => ({
|
||||
appMock: { getVersion: vi.fn(() => '1.0.51'), quit: vi.fn() },
|
||||
getCanonicalUserDataPathMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ app: appMock }))
|
||||
vi.mock('./persistence', () => ({ getCanonicalUserDataPath: getCanonicalUserDataPathMock }))
|
||||
|
||||
describe('serve update handoff', () => {
|
||||
let root: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
appMock.getVersion.mockReturnValue('1.0.51')
|
||||
appMock.quit.mockReset()
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-serve-handoff-'))
|
||||
getCanonicalUserDataPathMock.mockReturnValue(root)
|
||||
process.env[SERVE_UPDATE_HANDOFF_PATH_ENV] = getServeUpdateHandoffPath(root)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env[SERVE_UPDATE_HANDOFF_PATH_ENV]
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'persists install intent and a later deterministic failure for the serving pid',
|
||||
async () => {
|
||||
const {
|
||||
failServeUpdateHandoff,
|
||||
getServeUpdateHandoffFailure,
|
||||
hasServeUpdateSupervisor,
|
||||
requestServeUpdateHandoff
|
||||
} = await import('./serve-update-handoff')
|
||||
|
||||
expect(hasServeUpdateSupervisor()).toBe(true)
|
||||
expect(requestServeUpdateHandoff('1.0.61')).toBe(true)
|
||||
expect(readState(root)).toEqual({
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: process.pid
|
||||
})
|
||||
|
||||
failServeUpdateHandoff('native updater rejected the request')
|
||||
|
||||
expect(readState(root)).toEqual({
|
||||
schemaVersion: 1,
|
||||
phase: 'failed',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: process.pid,
|
||||
reason: 'native updater rejected the request'
|
||||
})
|
||||
expect(getServeUpdateHandoffFailure()).toBe('native updater rejected the request')
|
||||
|
||||
appMock.getVersion.mockReturnValue('1.0.61')
|
||||
expect(getServeUpdateHandoffFailure()).toBeNull()
|
||||
expect(existsSync(getServeUpdateHandoffPath(root))).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a handoff path outside the canonical user-data directory', async () => {
|
||||
process.env[SERVE_UPDATE_HANDOFF_PATH_ENV] = join(root, '..', 'untrusted.json')
|
||||
const { hasServeUpdateSupervisor, requestServeUpdateHandoff } =
|
||||
await import('./serve-update-handoff')
|
||||
|
||||
expect(hasServeUpdateSupervisor()).toBe(false)
|
||||
expect(requestServeUpdateHandoff('1.0.61')).toBe(false)
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'quits a supervised serve child when its CLI parent is lost',
|
||||
async () => {
|
||||
const parent = new EventEmitter()
|
||||
const { installServeSupervisorDisconnectQuit } = await import('./serve-update-handoff')
|
||||
|
||||
const removeListener = installServeSupervisorDisconnectQuit(true, parent)
|
||||
parent.emit('disconnect')
|
||||
|
||||
expect(appMock.quit).toHaveBeenCalledOnce()
|
||||
removeListener()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function readState(root: string) {
|
||||
return parseServeUpdateHandoffState(
|
||||
JSON.parse(readFileSync(getServeUpdateHandoffPath(root), 'utf8'))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
SERVE_UPDATE_HANDOFF_PATH_ENV,
|
||||
getServeUpdateHandoffPath,
|
||||
parseServeUpdateHandoffState,
|
||||
type ServeSupervisorMessage,
|
||||
type ServeUpdateHandoffState
|
||||
} from '../shared/serve-update-handoff'
|
||||
import { getCanonicalUserDataPath } from './persistence'
|
||||
|
||||
function getConfiguredHandoffPath(): string | null {
|
||||
const configuredPath = process.env[SERVE_UPDATE_HANDOFF_PATH_ENV]
|
||||
if (!configuredPath) {
|
||||
return null
|
||||
}
|
||||
const expectedPath = getServeUpdateHandoffPath(getCanonicalUserDataPath())
|
||||
return resolve(configuredPath) === resolve(expectedPath) ? expectedPath : null
|
||||
}
|
||||
|
||||
export function hasServeUpdateSupervisor(): boolean {
|
||||
return process.platform === 'darwin' && getConfiguredHandoffPath() !== null
|
||||
}
|
||||
|
||||
export function requestServeUpdateHandoff(targetVersion: string): boolean {
|
||||
const handoffPath = getConfiguredHandoffPath()
|
||||
if (!handoffPath || !targetVersion) {
|
||||
return false
|
||||
}
|
||||
return writeHandoffState(handoffPath, {
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: app.getVersion(),
|
||||
targetVersion,
|
||||
servingPid: process.pid
|
||||
})
|
||||
}
|
||||
|
||||
export function failServeUpdateHandoff(reason: string): void {
|
||||
const handoffPath = getConfiguredHandoffPath()
|
||||
if (!handoffPath) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const state = parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8')))
|
||||
if (state?.phase !== 'install-requested' || state.servingPid !== process.pid) {
|
||||
return
|
||||
}
|
||||
writeHandoffState(handoffPath, { ...state, phase: 'failed', reason })
|
||||
} catch {
|
||||
// The updater already reports the primary failure; a missing handoff file needs no second error.
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyServeSupervisorReady(runtimeId: string): void {
|
||||
if (!process.send || process.connected === false) {
|
||||
return
|
||||
}
|
||||
const message: ServeSupervisorMessage = {
|
||||
type: 'orca:serve-ready',
|
||||
version: app.getVersion(),
|
||||
runtimeId
|
||||
}
|
||||
try {
|
||||
process.send(message)
|
||||
} catch {
|
||||
// The disconnect listener owns parent-loss recovery; readiness reporting must not throw through startup.
|
||||
}
|
||||
}
|
||||
|
||||
export function installServeSupervisorDisconnectQuit(
|
||||
isServeMode: boolean,
|
||||
parent: {
|
||||
once(event: 'disconnect', listener: () => void): unknown
|
||||
off(event: 'disconnect', listener: () => void): unknown
|
||||
} = process
|
||||
): () => void {
|
||||
if (!isServeMode || !hasServeUpdateSupervisor()) {
|
||||
return () => undefined
|
||||
}
|
||||
const quit = (): void => app.quit()
|
||||
parent.once('disconnect', quit)
|
||||
return () => parent.off('disconnect', quit)
|
||||
}
|
||||
|
||||
export function getServeUpdateHandoffFailure(): string | null {
|
||||
const handoffPath = getConfiguredHandoffPath()
|
||||
if (!handoffPath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const state = parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8')))
|
||||
if (state?.phase !== 'failed') {
|
||||
return null
|
||||
}
|
||||
if (state.targetVersion === app.getVersion()) {
|
||||
unlinkSync(handoffPath)
|
||||
return null
|
||||
}
|
||||
return state.reason
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeHandoffState(path: string, state: ServeUpdateHandoffState): boolean {
|
||||
const temporaryPath = `${path}.${process.pid}.tmp`
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(temporaryPath, JSON.stringify(state), { mode: 0o600 })
|
||||
renameSync(temporaryPath, path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
renderServeReadiness,
|
||||
ServeReadinessPublisher,
|
||||
type ServeReadiness
|
||||
} from './serve-readiness'
|
||||
|
||||
const ready: ServeReadiness = {
|
||||
runtimeId: 'runtime-1',
|
||||
boundEndpoint: 'ws://0.0.0.0:6768',
|
||||
advertisedEndpoint: 'wss://orca.example.test/runtime',
|
||||
managedWslCliReconciliation: 'settled',
|
||||
pairing: {
|
||||
available: true,
|
||||
url: 'orca://pair?code=secret',
|
||||
endpoint: 'wss://orca.example.test/runtime',
|
||||
deviceId: 'device-1',
|
||||
webClientUrl: 'https://orca.example.test/runtime/web-index.html#pairing=secret',
|
||||
scope: 'runtime',
|
||||
qr: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('ServeReadinessPublisher', () => {
|
||||
it('writes one complete human-readable ready block', async () => {
|
||||
const write = vi.fn(async () => {})
|
||||
const publisher = new ServeReadinessPublisher(write)
|
||||
|
||||
await publisher.publish(ready, { mode: 'human' })
|
||||
|
||||
expect(write).toHaveBeenCalledOnce()
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Orca server ready\nBound endpoint: ws://0.0.0.0:6768\nAdvertised endpoint: wss://orca.example.test/runtime'
|
||||
)
|
||||
)
|
||||
expect(write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Pairing URL: orca://pair?code=secret\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes a versioned JSON contract with explicit endpoints and pairing availability', () => {
|
||||
expect(JSON.parse(renderServeReadiness(ready, { mode: 'json' }))).toEqual({
|
||||
type: 'orca_server_ready',
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'runtime-1',
|
||||
endpoint: 'ws://0.0.0.0:6768',
|
||||
boundEndpoint: 'ws://0.0.0.0:6768',
|
||||
advertisedEndpoint: 'wss://orca.example.test/runtime',
|
||||
managedWslCliReconciliation: 'settled',
|
||||
pairing: ready.pairing
|
||||
})
|
||||
})
|
||||
|
||||
it('reports unavailable pairing as an explicit machine-readable object', () => {
|
||||
const unavailable: ServeReadiness = {
|
||||
...ready,
|
||||
advertisedEndpoint: null,
|
||||
pairing: {
|
||||
available: false,
|
||||
reason: 'invalid_advertised_endpoint',
|
||||
guidance: 'Use a reachable address.'
|
||||
}
|
||||
}
|
||||
|
||||
const json = JSON.parse(renderServeReadiness(unavailable, { mode: 'json' }))
|
||||
expect(json.pairing).toEqual(unavailable.pairing)
|
||||
expect(renderServeReadiness(unavailable, { mode: 'human' })).toContain(
|
||||
'Pairing unavailable: invalid_advertised_endpoint\nPairing guidance: Use a reachable address.'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the recipe JSON contract', () => {
|
||||
expect(renderServeReadiness(ready, { mode: 'recipe-json', projectRoot: '/workspace' })).toBe(
|
||||
'{"schemaVersion":1,"pairingCode":"orca://pair?code=secret","projectRoot":"/workspace"}'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails recipe output with the unavailable reason and guidance', () => {
|
||||
const unavailable: ServeReadiness = {
|
||||
...ready,
|
||||
pairing: {
|
||||
available: false,
|
||||
reason: 'websocket_unavailable',
|
||||
guidance: 'Choose an unused --port.'
|
||||
}
|
||||
}
|
||||
expect(() =>
|
||||
renderServeReadiness(unavailable, { mode: 'recipe-json', projectRoot: '/workspace' })
|
||||
).toThrow('websocket_unavailable. Choose an unused --port.')
|
||||
})
|
||||
|
||||
it('rejects concurrent and later duplicate publications', async () => {
|
||||
let finishWrite: (() => void) | undefined
|
||||
const publisher = new ServeReadinessPublisher(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishWrite = resolve
|
||||
})
|
||||
)
|
||||
const first = publisher.publish(ready, { mode: 'json' })
|
||||
|
||||
await expect(publisher.publish(ready, { mode: 'json' })).rejects.toThrow(
|
||||
'publication already publishing'
|
||||
)
|
||||
finishWrite?.()
|
||||
await first
|
||||
await expect(publisher.publish(ready, { mode: 'json' })).rejects.toThrow(
|
||||
'publication already published'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces write failures and does not retry a partial contract', async () => {
|
||||
const publisher = new ServeReadinessPublisher(async () => {
|
||||
throw new Error('stdout closed')
|
||||
})
|
||||
|
||||
await expect(publisher.publish(ready, { mode: 'human' })).rejects.toThrow('stdout closed')
|
||||
await expect(publisher.publish(ready, { mode: 'human' })).rejects.toThrow(
|
||||
'publication already failed'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { PairingOfferUnavailableReason } from '../runtime/runtime-rpc'
|
||||
|
||||
export type ServePairingUnavailableReason = PairingOfferUnavailableReason | 'disabled_by_operator'
|
||||
|
||||
export type ServePairingReadiness =
|
||||
| {
|
||||
available: true
|
||||
url: string
|
||||
endpoint: string
|
||||
deviceId: string
|
||||
webClientUrl: string | null
|
||||
scope: 'runtime' | 'mobile'
|
||||
qr: string | null
|
||||
}
|
||||
| {
|
||||
available: false
|
||||
reason: ServePairingUnavailableReason
|
||||
guidance: string
|
||||
}
|
||||
|
||||
export type ServeReadiness = {
|
||||
runtimeId: string
|
||||
boundEndpoint: string | null
|
||||
advertisedEndpoint: string | null
|
||||
managedWslCliReconciliation: 'pending' | 'settled' | 'failed'
|
||||
pairing: ServePairingReadiness
|
||||
}
|
||||
|
||||
export type ServeReadinessOutput =
|
||||
| { mode: 'human' | 'json' }
|
||||
| { mode: 'recipe-json'; projectRoot: string }
|
||||
|
||||
type ReadinessWrite = (output: string) => Promise<void>
|
||||
|
||||
export class ServeReadinessPublisher {
|
||||
private state: 'pending' | 'publishing' | 'published' | 'failed' = 'pending'
|
||||
|
||||
constructor(private readonly write: ReadinessWrite = writeStdout) {}
|
||||
|
||||
async publish(readiness: ServeReadiness, output: ServeReadinessOutput): Promise<void> {
|
||||
if (this.state !== 'pending') {
|
||||
throw new Error(`Serve readiness publication already ${this.state}`)
|
||||
}
|
||||
this.state = 'publishing'
|
||||
try {
|
||||
await this.write(`${renderServeReadiness(readiness, output)}\n`)
|
||||
this.state = 'published'
|
||||
} catch (error) {
|
||||
this.state = 'failed'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderServeReadiness(
|
||||
readiness: ServeReadiness,
|
||||
output: ServeReadinessOutput
|
||||
): string {
|
||||
if (output.mode === 'recipe-json') {
|
||||
if (!readiness.pairing.available) {
|
||||
throw new Error(
|
||||
`Recipe JSON output requires runtime pairing: ${readiness.pairing.reason}. ${readiness.pairing.guidance}`
|
||||
)
|
||||
}
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
pairingCode: readiness.pairing.url,
|
||||
projectRoot: output.projectRoot
|
||||
})
|
||||
}
|
||||
if (output.mode === 'json') {
|
||||
return JSON.stringify({
|
||||
type: 'orca_server_ready',
|
||||
schemaVersion: 1,
|
||||
runtimeId: readiness.runtimeId,
|
||||
endpoint: readiness.boundEndpoint,
|
||||
boundEndpoint: readiness.boundEndpoint,
|
||||
advertisedEndpoint: readiness.advertisedEndpoint,
|
||||
managedWslCliReconciliation: readiness.managedWslCliReconciliation,
|
||||
pairing: readiness.pairing
|
||||
})
|
||||
}
|
||||
return renderHumanReadiness(readiness)
|
||||
}
|
||||
|
||||
function renderHumanReadiness(readiness: ServeReadiness): string {
|
||||
const lines = [
|
||||
'Orca server ready',
|
||||
`Bound endpoint: ${readiness.boundEndpoint ?? 'websocket unavailable'}`,
|
||||
`Advertised endpoint: ${readiness.advertisedEndpoint ?? 'unavailable'}`
|
||||
]
|
||||
if (readiness.pairing.available) {
|
||||
if (readiness.pairing.webClientUrl) {
|
||||
lines.push(`Web client URL: ${readiness.pairing.webClientUrl}`)
|
||||
}
|
||||
if (readiness.pairing.scope === 'mobile' && readiness.pairing.qr) {
|
||||
lines.push(`Mobile pairing QR:\n${readiness.pairing.qr}`)
|
||||
}
|
||||
lines.push(`Pairing URL: ${readiness.pairing.url}`)
|
||||
} else {
|
||||
lines.push(`Pairing unavailable: ${readiness.pairing.reason}`)
|
||||
lines.push(`Pairing guidance: ${readiness.pairing.guidance}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function writeStdout(output: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
process.stdout.write(output, (error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { reserveServeStdoutForReadiness } from './serve-stdout-boundary'
|
||||
|
||||
describe('reserveServeStdoutForReadiness', () => {
|
||||
it('routes console diagnostics to stderr', () => {
|
||||
const target = {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
log: vi.fn()
|
||||
}
|
||||
|
||||
reserveServeStdoutForReadiness(target)
|
||||
target.debug('debug')
|
||||
target.info('info')
|
||||
target.log('log')
|
||||
|
||||
expect(target.error.mock.calls).toEqual([['debug'], ['info'], ['log']])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
type DiagnosticConsole = Pick<Console, 'debug' | 'error' | 'info' | 'log'>
|
||||
|
||||
export function reserveServeStdoutForReadiness(target: DiagnosticConsole = console): void {
|
||||
// Why: stdout is the serve readiness API; route incidental diagnostics to stderr so JSON stays parseable.
|
||||
const writeDiagnostic = target.error.bind(target)
|
||||
target.debug = writeDiagnostic
|
||||
target.info = writeDiagnostic
|
||||
target.log = writeDiagnostic
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getAppImageCliArgs, maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect'
|
||||
|
||||
const commandNames = ['status', 'terminal']
|
||||
const commandNames = ['serve', 'status', 'terminal']
|
||||
|
||||
describe('AppImage CLI redirect', () => {
|
||||
it('detects direct AppImage CLI commands', () => {
|
||||
@@ -53,6 +53,34 @@ describe('AppImage CLI redirect', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('routes no-sandbox serve launches through the CLI', () => {
|
||||
expect(
|
||||
getAppImageCliArgs(
|
||||
['AppRun', '--no-sandbox', 'serve', '--port', '6768'],
|
||||
{ APPIMAGE: '/opt/orca' },
|
||||
{
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
commandNames
|
||||
}
|
||||
)
|
||||
).toEqual(['serve', '--port', '6768'])
|
||||
})
|
||||
|
||||
it('removes no-sandbox before forwarding CLI help', () => {
|
||||
expect(
|
||||
getAppImageCliArgs(
|
||||
['AppRun', '--no-sandbox', 'serve', '--help'],
|
||||
{ APPIMAGE: '/opt/orca' },
|
||||
{
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
commandNames
|
||||
}
|
||||
)
|
||||
).toEqual(['serve', '--help'])
|
||||
})
|
||||
|
||||
it('spawns the unpacked CLI entrypoint with Electron node mode', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-'))
|
||||
const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
||||
@@ -89,4 +117,31 @@ describe('AppImage CLI redirect', () => {
|
||||
expect(spawnOptions?.env).not.toHaveProperty('NODE_OPTIONS')
|
||||
expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE')
|
||||
})
|
||||
|
||||
it('forwards an explicit no-sandbox choice to the serve child', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-redirect-'))
|
||||
const cliEntryPath = join(root, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
||||
await mkdir(join(root, 'app.asar.unpacked', 'out', 'cli'), { recursive: true })
|
||||
await writeFile(cliEntryPath, '', 'utf8')
|
||||
const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 }))
|
||||
|
||||
maybeRedirectAppImageCliLaunch({
|
||||
argv: ['orca-linux.AppImage', '--no-sandbox', 'serve'],
|
||||
env: { APPIMAGE: '/opt/orca/orca-linux.AppImage' },
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
resourcesPath: root,
|
||||
execPath: '/opt/orca/orca-ide',
|
||||
commandNames,
|
||||
spawn: spawn as never
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'/opt/orca/orca-ide',
|
||||
[cliEntryPath, 'serve'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ ORCA_APPIMAGE_NO_SANDBOX: '1' })
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,6 +113,10 @@ export function maybeRedirectAppImageCliLaunch(options: RedirectOptions = {}): R
|
||||
}
|
||||
|
||||
const childEnv = buildElectronRunAsNodeEnv(env)
|
||||
if (argv.slice(1).includes('--no-sandbox')) {
|
||||
// Why: the operator explicitly disabled Chromium's sandbox; preserve that choice when `serve` launches the Electron child.
|
||||
childEnv.ORCA_APPIMAGE_NO_SANDBOX = '1'
|
||||
}
|
||||
const result = spawn(execPath, [cliEntryPath, ...cliArgs], {
|
||||
env: childEnv,
|
||||
stdio: 'inherit'
|
||||
@@ -143,16 +147,17 @@ export function getAppImageCliArgs(
|
||||
}
|
||||
|
||||
const args = argv.slice(1)
|
||||
if (args.length === 0 || args.some((arg) => APPIMAGE_DESKTOP_FLAGS.has(arg))) {
|
||||
if (args.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (args.some((arg) => HELP_FLAGS.has(arg))) {
|
||||
return args
|
||||
const cliArgs = args.filter((arg) => !APPIMAGE_DESKTOP_FLAGS.has(arg))
|
||||
if (cliArgs.some((arg) => HELP_FLAGS.has(arg))) {
|
||||
return cliArgs
|
||||
}
|
||||
|
||||
const commandNames = new Set(options.commandNames)
|
||||
const firstPositional = findFirstCommandCandidate(args)
|
||||
return firstPositional && commandNames.has(firstPositional) ? args : null
|
||||
const firstPositional = findFirstCommandCandidate(cliArgs)
|
||||
return firstPositional && commandNames.has(firstPositional) ? cliArgs : null
|
||||
}
|
||||
|
||||
function findFirstCommandCandidate(args: string[]): string | null {
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('startup ordering', () => {
|
||||
|
||||
// Why: the barrier fails open, so the serve-ready payload must carry the
|
||||
// reconciliation state and the bounded wait must be traceable via a milestone.
|
||||
const readyStart = source.indexOf("type: 'orca_server_ready'")
|
||||
const readyStart = source.indexOf('await serveReadinessPublisher.publish(')
|
||||
const readyEnd = source.indexOf('pairing: pairing.available', readyStart)
|
||||
const readyPayload = source.slice(readyStart, readyEnd)
|
||||
expect(readyPayload).toContain('managedWslCliReconciliation: managedWslCliReconciliationStatus')
|
||||
@@ -72,6 +72,15 @@ describe('startup ordering', () => {
|
||||
expect(source).toContain("logStartupMilestone('wsl-cli-barrier-resolved'")
|
||||
})
|
||||
|
||||
it('notifies the serve supervisor only after publishing readiness', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
|
||||
const readyStart = source.indexOf('await serveReadinessPublisher.publish(')
|
||||
const supervisorReady = source.indexOf('notifyServeSupervisorReady(', readyStart)
|
||||
|
||||
expect(readyStart).toBeGreaterThanOrEqual(0)
|
||||
expect(supervisorReady).toBeGreaterThan(readyStart)
|
||||
})
|
||||
|
||||
it('does not run the rate-limit quota fetch before the first window can show results', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
|
||||
const attachIndex = source.indexOf('rateLimits.attach(window)')
|
||||
|
||||
@@ -77,7 +77,9 @@ describe('ensureVirtualDisplayForHeadlessServe', () => {
|
||||
expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(process.env.DISPLAY).toBe(':0')
|
||||
expect(appMock.disableHardwareAcceleration).toHaveBeenCalled()
|
||||
expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage')
|
||||
expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu')
|
||||
})
|
||||
|
||||
it('reports unsupported (no spawn) when Xvfb is not installed', async () => {
|
||||
|
||||
@@ -20,6 +20,9 @@ function configureHeadlessServeChromiumFlags(): void {
|
||||
// exhausted shared-memory mount as ENOSPC and can fatal in utility services
|
||||
// such as font_data. Keep browser panes on disk-backed temp storage instead.
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage')
|
||||
// Why: externally managed displays are commonly Xvfb too; a GPU-process fork can trap before serve readiness.
|
||||
app.disableHardwareAcceleration()
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
}
|
||||
|
||||
function xvfbSocketPath(displayNumber: number): string {
|
||||
@@ -159,10 +162,6 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo
|
||||
}
|
||||
|
||||
process.env.DISPLAY = VIRTUAL_DISPLAY
|
||||
// Why: the offscreen browser must use software rendering; a virtual display has
|
||||
// no GPU. Must be set before app.whenReady (this runs at module load).
|
||||
app.disableHardwareAcceleration()
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
// Why: don't leave a stray Xvfb process behind when serve exits.
|
||||
app.once('will-quit', stopVirtualDisplay)
|
||||
|
||||
@@ -46,4 +46,8 @@ describe('serve desktop activation wiring', () => {
|
||||
expect(settleIndex).toBeGreaterThan(rpcIndex)
|
||||
expect(source).not.toContain('runtime.syncWindowGraph(0,')
|
||||
})
|
||||
|
||||
it('keeps the headless install policy after desktop promotion', () => {
|
||||
expect(source).toContain('updateInstallMode: resolveUpdateInstallMode(isServeMode)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,7 @@ type UpdaterHandlerContext = {
|
||||
markUpdateAvailableEventPending: (attemptId: number | null) => void
|
||||
markMissingManifestPrereleaseFallbackChecking: () => void
|
||||
performQuitAndInstall: () => void | Promise<void>
|
||||
shouldDeferMacQuitForInstall: () => boolean
|
||||
recordCompletedUpdateCheck: () => void
|
||||
sendCheckFailureStatus: (
|
||||
message: string,
|
||||
@@ -77,6 +78,7 @@ export function registerAutoUpdaterHandlers({
|
||||
markUpdateAvailableEventPending,
|
||||
markMissingManifestPrereleaseFallbackChecking,
|
||||
performQuitAndInstall,
|
||||
shouldDeferMacQuitForInstall,
|
||||
recordCompletedUpdateCheck,
|
||||
sendCheckFailureStatus,
|
||||
sendErrorStatus,
|
||||
@@ -104,6 +106,9 @@ export function registerAutoUpdaterHandlers({
|
||||
}
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (!shouldDeferMacQuitForInstall()) {
|
||||
return
|
||||
}
|
||||
if (consumeMacInstallGuardBypass()) {
|
||||
recordUpdaterLifecycle('macos_before_quit_guard_bypassed')
|
||||
return
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
appMock,
|
||||
autoUpdaterMock,
|
||||
nativeUpdaterMock,
|
||||
killAllPtyMock,
|
||||
recordUpdaterLifecycleMock,
|
||||
requestServeUpdateHandoffMock,
|
||||
failServeUpdateHandoffMock,
|
||||
resetHandlers
|
||||
} = vi.hoisted(() => {
|
||||
const appHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
const updaterHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
|
||||
const emit = (
|
||||
handlers: Map<string, ((...args: unknown[]) => void)[]>,
|
||||
event: string,
|
||||
...args: unknown[]
|
||||
): void => {
|
||||
for (const handler of handlers.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
}
|
||||
|
||||
const appMock = {
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.0.51'),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
appHandlers.set(event, [...(appHandlers.get(event) ?? []), handler])
|
||||
return appMock
|
||||
}),
|
||||
emit: (event: string, ...args: unknown[]) => emit(appHandlers, event, ...args),
|
||||
quit: vi.fn()
|
||||
}
|
||||
|
||||
const autoUpdaterMock = {
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
autoRunAppAfterInstall: true,
|
||||
allowPrerelease: false,
|
||||
checkForUpdates: vi.fn().mockResolvedValue(null),
|
||||
downloadUpdate: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
setFeedURL: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
updaterHandlers.set(event, [...(updaterHandlers.get(event) ?? []), handler])
|
||||
return autoUpdaterMock
|
||||
}),
|
||||
emit: (event: string, ...args: unknown[]) => emit(updaterHandlers, event, ...args)
|
||||
}
|
||||
|
||||
return {
|
||||
appMock,
|
||||
autoUpdaterMock,
|
||||
nativeUpdaterMock: { on: vi.fn() },
|
||||
killAllPtyMock: vi.fn(),
|
||||
recordUpdaterLifecycleMock: vi.fn(),
|
||||
requestServeUpdateHandoffMock: vi.fn(() => true),
|
||||
failServeUpdateHandoffMock: vi.fn(),
|
||||
resetHandlers: () => {
|
||||
appHandlers.clear()
|
||||
updaterHandlers.clear()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: appMock,
|
||||
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
||||
autoUpdater: nativeUpdaterMock,
|
||||
powerMonitor: { on: vi.fn() },
|
||||
shell: { openExternal: vi.fn() },
|
||||
net: { fetch: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock }))
|
||||
vi.mock('./electron-updater-loader', () => ({ loadElectronAutoUpdater: () => autoUpdaterMock }))
|
||||
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } }))
|
||||
vi.mock('./ipc/pty', () => ({ killAllPty: killAllPtyMock }))
|
||||
vi.mock('./updater-changelog', () => ({ fetchChangelog: vi.fn().mockResolvedValue(null) }))
|
||||
vi.mock('./updater-nudge', () => ({
|
||||
fetchNudge: vi.fn().mockResolvedValue(null),
|
||||
shouldApplyNudge: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
vi.mock('./updater-prerelease-feed', () => ({
|
||||
fetchNewerReleaseTagsWithReadiness: vi.fn().mockResolvedValue({
|
||||
tags: ['v1.0.61'],
|
||||
state: 'ready'
|
||||
}),
|
||||
getReleaseDownloadUrl: vi.fn()
|
||||
}))
|
||||
vi.mock('./update-install-exit-watchdog', () => ({
|
||||
armUpdateInstallExitWatchdog: vi.fn(),
|
||||
disarmUpdateInstallExitWatchdog: vi.fn()
|
||||
}))
|
||||
vi.mock('./updater-lifecycle-diagnostics', () => ({
|
||||
recordUpdaterLifecycle: recordUpdaterLifecycleMock
|
||||
}))
|
||||
vi.mock('./serve-update-handoff', () => ({
|
||||
failServeUpdateHandoff: failServeUpdateHandoffMock,
|
||||
getServeUpdateHandoffFailure: vi.fn(() => null),
|
||||
hasServeUpdateSupervisor: vi.fn(() => true),
|
||||
requestServeUpdateHandoff: requestServeUpdateHandoffMock
|
||||
}))
|
||||
|
||||
describe('headless serve update install handoff', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null)
|
||||
autoUpdaterMock.downloadUpdate.mockReset().mockResolvedValue([])
|
||||
autoUpdaterMock.quitAndInstall.mockReset()
|
||||
autoUpdaterMock.setFeedURL.mockReset()
|
||||
autoUpdaterMock.on.mockClear()
|
||||
autoUpdaterMock.autoInstallOnAppQuit = false
|
||||
autoUpdaterMock.autoRunAppAfterInstall = true
|
||||
nativeUpdaterMock.on.mockReset()
|
||||
appMock.on.mockClear()
|
||||
appMock.quit.mockReset()
|
||||
killAllPtyMock.mockReset()
|
||||
recordUpdaterLifecycleMock.mockReset()
|
||||
requestServeUpdateHandoffMock.mockReset().mockReturnValue(true)
|
||||
failServeUpdateHandoffMock.mockReset()
|
||||
resetHandlers()
|
||||
})
|
||||
|
||||
it('defers install before disconnecting the serving owner or starting session cleanup', async () => {
|
||||
const lifecycle: string[] = []
|
||||
const pendingInstaller = { version: '1.0.61', staged: true }
|
||||
const servingOwner = { version: '1.0.51', connectedClients: 2, verified: true }
|
||||
const replacementOwner: { version: string; verified: boolean } | null = null
|
||||
const send = vi.fn()
|
||||
const beginSessionCleanup = vi.fn(() => lifecycle.push('session-cleanup'))
|
||||
const disconnectPairedClients = vi.fn(() => {
|
||||
lifecycle.push('paired-clients-disconnected')
|
||||
servingOwner.connectedClients = 0
|
||||
servingOwner.verified = false
|
||||
})
|
||||
|
||||
appMock.on('will-quit', disconnectPairedClients)
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => {
|
||||
autoUpdaterMock.emit('update-available', { version: pendingInstaller.version })
|
||||
})
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
lifecycle.push('native-quit-and-install')
|
||||
appMock.emit('will-quit', { preventDefault: vi.fn() })
|
||||
})
|
||||
killAllPtyMock.mockImplementation(beginSessionCleanup)
|
||||
|
||||
const { checkForUpdatesFromMenu, quitAndInstall, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater(
|
||||
{ webContents: { send } } as never,
|
||||
{
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'unsupported-headless-serve'
|
||||
} as never
|
||||
)
|
||||
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
autoUpdaterMock.emit('update-downloaded', { version: pendingInstaller.version })
|
||||
const nativeReadyHandler = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1] as (() => void) | undefined
|
||||
nativeReadyHandler?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({ state: 'downloaded', version: pendingInstaller.version })
|
||||
)
|
||||
|
||||
quitAndInstall()
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
const statuses = send.mock.calls
|
||||
.filter(([channel]) => channel === 'updater:status')
|
||||
.map(([, status]) => status)
|
||||
expect({
|
||||
nativeInstallCalls: autoUpdaterMock.quitAndInstall.mock.calls.length,
|
||||
pairedClientDisconnects: disconnectPairedClients.mock.calls.length,
|
||||
sessionCleanupStarts: beginSessionCleanup.mock.calls.length,
|
||||
servingOwner,
|
||||
replacementOwner,
|
||||
pendingInstaller,
|
||||
deferredStatusVisible: statuses.some(
|
||||
(status) =>
|
||||
status &&
|
||||
typeof status === 'object' &&
|
||||
'state' in status &&
|
||||
status.state === 'error' &&
|
||||
'message' in status &&
|
||||
typeof status.message === 'string' &&
|
||||
status.message.includes('orca serve')
|
||||
),
|
||||
deferralDiagnostics: recordUpdaterLifecycleMock.mock.calls.filter(
|
||||
([event]) => event === 'headless_serve_install_deferred'
|
||||
).length,
|
||||
lifecycle
|
||||
}).toEqual({
|
||||
nativeInstallCalls: 0,
|
||||
pairedClientDisconnects: 0,
|
||||
sessionCleanupStarts: 0,
|
||||
servingOwner: { version: '1.0.51', connectedClients: 2, verified: true },
|
||||
replacementOwner: null,
|
||||
pendingInstaller: { version: '1.0.61', staged: true },
|
||||
deferredStatusVisible: true,
|
||||
deferralDiagnostics: 1,
|
||||
lifecycle: []
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks staging and install-on-quit while still reporting an available update', async () => {
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, downloadUpdate, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'unsupported-headless-serve'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({ state: 'available', version: '1.0.61' })
|
||||
)
|
||||
|
||||
downloadUpdate()
|
||||
downloadUpdate()
|
||||
|
||||
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false)
|
||||
expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled()
|
||||
expect(
|
||||
recordUpdaterLifecycleMock.mock.calls.filter(
|
||||
([event, data]) =>
|
||||
event === 'headless_serve_install_deferred' &&
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'phase' in data &&
|
||||
data.phase === 'download'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
send.mock.calls.filter(
|
||||
([channel, status]) => channel === 'updater:status' && status?.state === 'error'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hands a supervised install to the serve parent before native quit and cleanup', async () => {
|
||||
const lifecycle: string[] = []
|
||||
const daemonSession = { alive: true }
|
||||
const send = vi.fn()
|
||||
const disconnectPairedClients = vi.fn(() => lifecycle.push('paired-clients-disconnected'))
|
||||
appMock.on('will-quit', disconnectPairedClients)
|
||||
requestServeUpdateHandoffMock.mockImplementation(() => {
|
||||
lifecycle.push('handoff-persisted')
|
||||
return true
|
||||
})
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
lifecycle.push('native-quit-and-install')
|
||||
appMock.emit('will-quit', { preventDefault: vi.fn() })
|
||||
})
|
||||
killAllPtyMock.mockImplementation(() => lifecycle.push('in-process-pty-cleanup'))
|
||||
|
||||
const { checkForUpdatesFromMenu, downloadUpdate, quitAndInstall, setupAutoUpdater } =
|
||||
await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'supervised-headless-serve',
|
||||
onBeforeQuit: () => {
|
||||
lifecycle.push('pre-quit-checkpoint')
|
||||
}
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
downloadUpdate()
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
const nativeReadyHandler = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1] as (() => void) | undefined
|
||||
nativeReadyHandler?.()
|
||||
|
||||
quitAndInstall()
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
quitAndInstall()
|
||||
|
||||
expect(requestServeUpdateHandoffMock).toHaveBeenCalledWith('1.0.61')
|
||||
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false)
|
||||
expect(autoUpdaterMock.autoRunAppAfterInstall).toBe(false)
|
||||
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledOnce()
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(true, false)
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce()
|
||||
expect(daemonSession).toEqual({ alive: true })
|
||||
expect(lifecycle).toEqual([
|
||||
'pre-quit-checkpoint',
|
||||
'handoff-persisted',
|
||||
'native-quit-and-install',
|
||||
'paired-clients-disconnected',
|
||||
'in-process-pty-cleanup'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the serving owner intact when the supervisor handoff cannot be persisted', async () => {
|
||||
const send = vi.fn()
|
||||
requestServeUpdateHandoffMock.mockReturnValue(false)
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, quitAndInstall, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'supervised-headless-serve'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
const nativeReadyHandler = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1] as (() => void) | undefined
|
||||
nativeReadyHandler?.()
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(requestServeUpdateHandoffMock).toHaveBeenCalledOnce()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(killAllPtyMock).not.toHaveBeenCalled()
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({
|
||||
state: 'error',
|
||||
message: expect.stringContaining('supervised server restart')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'defers a pre-staged macOS update resumed from the native-ready continuation',
|
||||
async () => {
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'unsupported-headless-serve'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
|
||||
const { deferMacQuitUntilInstallerReady } = await import('./updater-mac-install')
|
||||
expect(
|
||||
deferMacQuitUntilInstallerReady(
|
||||
{ state: 'downloading', percent: 100, version: '1.0.61' },
|
||||
true,
|
||||
() => '1.0.61',
|
||||
send
|
||||
)
|
||||
).toBe(true)
|
||||
const nativeReadyHandler = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1] as (() => void) | undefined
|
||||
nativeReadyHandler?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(killAllPtyMock).not.toHaveBeenCalled()
|
||||
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
|
||||
'headless_serve_install_deferred',
|
||||
{ phase: 'install', version: '1.0.61' },
|
||||
expect.objectContaining({ level: 'warn' })
|
||||
)
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({ state: 'error', message: expect.stringContaining('orca serve') })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'does not reinterpret an ordinary headless app quit as an update install request',
|
||||
async () => {
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'unsupported-headless-serve'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(appMock.quit).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'defers before the macOS installer-readiness timeout can quit the serving owner',
|
||||
async () => {
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, quitAndInstall, setupAutoUpdater } =
|
||||
await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'unsupported-headless-serve'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
expect(appMock.quit).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(killAllPtyMock).not.toHaveBeenCalled()
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({ state: 'error', message: expect.stringContaining('orca serve') })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves interactive download and install-on-quit behavior', async () => {
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, downloadUpdate, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'interactive'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
downloadUpdate()
|
||||
|
||||
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true)
|
||||
expect(autoUpdaterMock.autoRunAppAfterInstall).toBe(true)
|
||||
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith(
|
||||
'headless_serve_install_deferred',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -50,12 +50,14 @@ const {
|
||||
autoUpdaterMock.setFeedURL.mockClear()
|
||||
autoUpdaterMock.updateConfigPath = undefined
|
||||
autoUpdaterMock.allowPrerelease = false
|
||||
autoUpdaterMock.autoRunAppAfterInstall = true
|
||||
delete (autoUpdaterMock as Record<string, unknown>).verifyUpdateCodeSignature
|
||||
}
|
||||
|
||||
const autoUpdaterMock = {
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
autoRunAppAfterInstall: true,
|
||||
allowPrerelease: false,
|
||||
on,
|
||||
checkForUpdates: vi.fn(),
|
||||
|
||||
+97
-4
@@ -33,12 +33,22 @@ import {
|
||||
getReleaseDownloadUrl
|
||||
} from './updater-prerelease-feed'
|
||||
import { fetchNudge, shouldApplyNudge } from './updater-nudge'
|
||||
import {
|
||||
failServeUpdateHandoff,
|
||||
getServeUpdateHandoffFailure,
|
||||
hasServeUpdateSupervisor,
|
||||
requestServeUpdateHandoff
|
||||
} from './serve-update-handoff'
|
||||
|
||||
type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
|
||||
type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
|
||||
type PrimaryEventSuppression = { failureKey: string; error: unknown }
|
||||
type UpdateCheckVariant = 'default' | 'prerelease' | 'perf'
|
||||
type ReleaseFeedPreflightResult = 'ready' | 'not-available'
|
||||
export type UpdateInstallMode =
|
||||
| 'interactive'
|
||||
| 'supervised-headless-serve'
|
||||
| 'unsupported-headless-serve'
|
||||
|
||||
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
|
||||
@@ -66,6 +76,8 @@ let autoUpdateCheckTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let nudgeCheckTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingQuitAndInstallTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let quitAndInstallInProgress = false
|
||||
let updateInstallMode: UpdateInstallMode = 'interactive'
|
||||
let lastInstallDeferralVersion = { download: null as string | null, install: null as string | null }
|
||||
// Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer.
|
||||
let updateInstallCommitted = false
|
||||
// Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery.
|
||||
@@ -520,6 +532,36 @@ function getPendingInstallVersion(): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean {
|
||||
if (updateInstallMode !== 'unsupported-headless-serve') {
|
||||
return false
|
||||
}
|
||||
const diagnosticVersion = version || 'unknown'
|
||||
if (lastInstallDeferralVersion[phase] !== diagnosticVersion) {
|
||||
lastInstallDeferralVersion[phase] = diagnosticVersion
|
||||
recordUpdaterLifecycle(
|
||||
'headless_serve_install_deferred',
|
||||
{ phase, version: version || null },
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'Update install deferred while hosting orca serve'
|
||||
}
|
||||
)
|
||||
}
|
||||
sendErrorStatus(
|
||||
'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.',
|
||||
true
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
export function resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode {
|
||||
if (!isServeMode) {
|
||||
return 'interactive'
|
||||
}
|
||||
return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve'
|
||||
}
|
||||
|
||||
function getCheckFailureKey(message: string, userInitiated?: boolean): string {
|
||||
return `${userInitiated ? 'user' : 'auto'}:${message}`
|
||||
}
|
||||
@@ -541,19 +583,23 @@ async function performQuitAndInstall(): Promise<void> {
|
||||
recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' })
|
||||
return
|
||||
}
|
||||
quitAndInstallInProgress = true
|
||||
|
||||
if (pendingQuitAndInstallTimer) {
|
||||
clearTimeout(pendingQuitAndInstallTimer)
|
||||
pendingQuitAndInstallTimer = null
|
||||
}
|
||||
|
||||
const pendingVersion = getPendingInstallVersion()
|
||||
if (deferHeadlessServeInstall('install', pendingVersion)) {
|
||||
return
|
||||
}
|
||||
quitAndInstallInProgress = true
|
||||
|
||||
markMacQuitAndInstallInFlight()
|
||||
|
||||
// Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle.
|
||||
quittingForUpdate = true
|
||||
|
||||
const pendingVersion = getPendingInstallVersion()
|
||||
try {
|
||||
await withUpdaterSpan({ stage: 'install' }, async (span) => {
|
||||
span.setAttribute('updater.version', pendingVersion || 'unknown')
|
||||
@@ -570,6 +616,26 @@ async function performQuitAndInstall(): Promise<void> {
|
||||
await runBeforeUpdateQuitCleanup()
|
||||
span.addEvent('pre_quit_cleanup_done')
|
||||
|
||||
if (
|
||||
updateInstallMode === 'supervised-headless-serve' &&
|
||||
!requestServeUpdateHandoff(pendingVersion)
|
||||
) {
|
||||
recordUpdaterLifecycle(
|
||||
'headless_serve_handoff_failed',
|
||||
{ version: pendingVersion || null },
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'Could not persist supervised serve update handoff'
|
||||
}
|
||||
)
|
||||
sendErrorStatus(
|
||||
'Could not prepare the supervised server restart. Orca remains running.',
|
||||
true
|
||||
)
|
||||
resetQuitForUpdateState()
|
||||
return
|
||||
}
|
||||
|
||||
recordUpdaterLifecycle('quit_and_install_invoking_native', {
|
||||
version: pendingVersion || null
|
||||
})
|
||||
@@ -580,7 +646,8 @@ async function performQuitAndInstall(): Promise<void> {
|
||||
// Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure.
|
||||
quitAndInstallNativeInvoked = true
|
||||
// Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact.
|
||||
getAutoUpdater().quitAndInstall(false, true)
|
||||
const supervisorOwnsRelaunch = updateInstallMode === 'supervised-headless-serve'
|
||||
getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch)
|
||||
span.addEvent('native_quit_and_install_invoked')
|
||||
|
||||
// Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran.
|
||||
@@ -606,6 +673,7 @@ async function performQuitAndInstall(): Promise<void> {
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
failServeUpdateHandoff('Could not invoke the native updater.')
|
||||
resetQuitForUpdateState()
|
||||
recordUpdaterLifecycle(
|
||||
'quit_and_install_failed',
|
||||
@@ -635,6 +703,7 @@ function handleQuitAndInstallFailure(): boolean {
|
||||
if (!quitAndInstallInProgress || !quitAndInstallNativeInvoked || updateInstallCommitted) {
|
||||
return false
|
||||
}
|
||||
failServeUpdateHandoff('The native updater rejected the install request.')
|
||||
resetQuitForUpdateState()
|
||||
recordUpdaterLifecycle('quit_and_install_failed_via_event', undefined, {
|
||||
level: 'warn',
|
||||
@@ -1164,6 +1233,10 @@ export function quitAndInstall(): void {
|
||||
return
|
||||
}
|
||||
|
||||
if (deferHeadlessServeInstall('install', getPendingInstallVersion())) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
deferMacQuitUntilInstallerReady(
|
||||
currentStatus,
|
||||
@@ -1256,6 +1329,7 @@ export function setupAutoUpdater(
|
||||
getDismissedUpdateNudgeId?: () => string | null
|
||||
setPendingUpdateNudgeId?: (id: string | null) => void
|
||||
setDismissedUpdateNudgeId?: (id: string | null) => void
|
||||
installMode?: UpdateInstallMode
|
||||
}
|
||||
): void {
|
||||
mainWindowRef = mainWindow
|
||||
@@ -1266,6 +1340,18 @@ export function setupAutoUpdater(
|
||||
_getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null
|
||||
_setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null
|
||||
_setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null
|
||||
updateInstallMode = opts?.installMode ?? 'interactive'
|
||||
lastInstallDeferralVersion = { download: null, install: null }
|
||||
|
||||
const serveHandoffFailure = getServeUpdateHandoffFailure()
|
||||
if (serveHandoffFailure) {
|
||||
recordUpdaterLifecycle(
|
||||
'headless_serve_handoff_failed',
|
||||
{ reason: serveHandoffFailure },
|
||||
{ level: 'warn', message: 'Supervised serve update did not complete' }
|
||||
)
|
||||
sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true)
|
||||
}
|
||||
|
||||
if (!app.isPackaged && !is.dev) {
|
||||
return
|
||||
@@ -1276,7 +1362,10 @@ export function setupAutoUpdater(
|
||||
|
||||
const autoUpdater = getAutoUpdater()
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
// Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly.
|
||||
autoUpdater.autoInstallOnAppQuit = updateInstallMode === 'interactive'
|
||||
// Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner.
|
||||
autoUpdater.autoRunAppAfterInstall = updateInstallMode === 'interactive'
|
||||
|
||||
// Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible.
|
||||
autoUpdater.logger = {
|
||||
@@ -1322,6 +1411,7 @@ export function setupAutoUpdater(
|
||||
sendCheckFailureStatus,
|
||||
sendErrorStatus,
|
||||
markMissingManifestPrereleaseFallbackChecking,
|
||||
shouldDeferMacQuitForInstall: () => updateInstallMode === 'interactive',
|
||||
shouldSuppressMissingManifestPrereleaseFallbackEvent,
|
||||
suppressMissingManifestPrereleaseFallbackPromiseFailure,
|
||||
recordCompletedUpdateCheck,
|
||||
@@ -1389,6 +1479,9 @@ export function downloadUpdate(): void {
|
||||
if (!version) {
|
||||
return
|
||||
}
|
||||
if (deferHeadlessServeInstall('download', version)) {
|
||||
return
|
||||
}
|
||||
downloadInFlight = true
|
||||
beginMacUpdateDownload()
|
||||
// Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert.
|
||||
|
||||
@@ -260,13 +260,17 @@ describe('attachMainWindowServices', () => {
|
||||
createRuntime() as never,
|
||||
undefined,
|
||||
undefined,
|
||||
{ onBeforeUpdateQuit }
|
||||
{ onBeforeUpdateQuit, updateInstallMode: 'supervised-headless-serve' }
|
||||
)
|
||||
|
||||
// Deferred to first paint — must not be configured at attach time.
|
||||
expect(setupAutoUpdaterMock).not.toHaveBeenCalled()
|
||||
await fireReadyToShow(mainWindow)
|
||||
expect(setupAutoUpdaterMock).toHaveBeenCalledTimes(1)
|
||||
expect(setupAutoUpdaterMock).toHaveBeenCalledWith(
|
||||
mainWindow,
|
||||
expect.objectContaining({ installMode: 'supervised-headless-serve' })
|
||||
)
|
||||
await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit()
|
||||
|
||||
expect(onBeforeUpdateQuit).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
getUpdateStatus,
|
||||
quitAndInstall,
|
||||
setupAutoUpdater,
|
||||
dismissNudge
|
||||
dismissNudge,
|
||||
type UpdateInstallMode
|
||||
} from '../updater'
|
||||
import { scheduleHistoryGc } from '../terminal-history'
|
||||
import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry'
|
||||
@@ -82,6 +83,7 @@ export function attachMainWindowServices(
|
||||
// Why: lets the PTY orphan sweep skip the one crash-recovery reload (#5787).
|
||||
isRecoveryReloadInFlight?: (webContentsId: number) => boolean
|
||||
onBeforeUpdateQuit?: () => void | Promise<void>
|
||||
updateInstallMode?: UpdateInstallMode
|
||||
}
|
||||
): void {
|
||||
registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload)
|
||||
@@ -158,7 +160,8 @@ export function attachMainWindowServices(
|
||||
},
|
||||
setDismissedUpdateNudgeId: (id) => {
|
||||
store.updateUI({ dismissedUpdateNudgeId: id })
|
||||
}
|
||||
},
|
||||
installMode: options?.updateInstallMode
|
||||
})
|
||||
logStartupMilestone('updater-setup-done')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
handlers,
|
||||
clipboardReadText,
|
||||
clipboardWriteText,
|
||||
clipboardReadImage,
|
||||
clipboardWriteImage,
|
||||
clipboardWriteBuffer,
|
||||
isDashboardPopoutRenderer
|
||||
} = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (...args: unknown[]) => unknown>(),
|
||||
clipboardReadText: vi.fn(() => 'terminal clipboard text'),
|
||||
clipboardWriteText: vi.fn(),
|
||||
clipboardReadImage: vi.fn(),
|
||||
clipboardWriteImage: vi.fn(),
|
||||
clipboardWriteBuffer: vi.fn(),
|
||||
isDashboardPopoutRenderer: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => '/tmp') },
|
||||
clipboard: {
|
||||
readText: clipboardReadText,
|
||||
readBuffer: vi.fn(),
|
||||
writeText: clipboardWriteText,
|
||||
readImage: clipboardReadImage,
|
||||
writeImage: clipboardWriteImage,
|
||||
writeBuffer: clipboardWriteBuffer
|
||||
},
|
||||
ipcMain: {
|
||||
removeHandler: (channel: string) => handlers.delete(channel),
|
||||
handle: (channel: string, handler: (...args: unknown[]) => unknown) =>
|
||||
handlers.set(channel, handler)
|
||||
},
|
||||
nativeImage: { createFromBuffer: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer }))
|
||||
vi.mock('./clipboard-remote-file-copy', () => ({
|
||||
cleanupExpiredRemoteClipboardFiles: vi.fn(async () => undefined),
|
||||
writeRemoteFileToClipboard: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
registerClipboardHandlers,
|
||||
setTrustedClipboardRendererWebContentsId
|
||||
} from './clipboard-ipc-handlers'
|
||||
|
||||
const popoutEvent = {
|
||||
sender: {
|
||||
id: 42,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///popout.html'
|
||||
}
|
||||
}
|
||||
|
||||
describe('dashboard popout clipboard access', () => {
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
vi.clearAllMocks()
|
||||
isDashboardPopoutRenderer.mockReturnValue(true)
|
||||
clipboardReadText.mockReturnValue('terminal clipboard text')
|
||||
setTrustedClipboardRendererWebContentsId(17)
|
||||
registerClipboardHandlers({} as never)
|
||||
})
|
||||
|
||||
it('allows terminal text copy and paste through the exact popout renderer', async () => {
|
||||
await expect(handlers.get('clipboard:readText')?.(popoutEvent)).resolves.toBe(
|
||||
'terminal clipboard text'
|
||||
)
|
||||
await expect(
|
||||
handlers.get('clipboard:writeText')?.(popoutEvent, 'terminal selection')
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith('terminal selection')
|
||||
})
|
||||
|
||||
it('does not extend popout authority to selection, image, file, or remote clipboard APIs', async () => {
|
||||
await expect(handlers.get('clipboard:readSelectionText')?.(popoutEvent)).rejects.toThrow(
|
||||
'Unauthorized clipboard IPC sender'
|
||||
)
|
||||
await expect(
|
||||
handlers.get('clipboard:writeSelectionText')?.(popoutEvent, 'primary selection')
|
||||
).rejects.toThrow('Unauthorized clipboard IPC sender')
|
||||
await expect(handlers.get('clipboard:saveImageAsTempFile')?.(popoutEvent)).rejects.toThrow(
|
||||
'Unauthorized clipboard IPC sender'
|
||||
)
|
||||
expect(() =>
|
||||
handlers.get('clipboard:writeFile')?.(popoutEvent, {
|
||||
filePath: '/tmp/copied-file.txt',
|
||||
connectionId: 'ssh-secret'
|
||||
})
|
||||
).toThrow('Unauthorized clipboard IPC sender')
|
||||
expect(() =>
|
||||
handlers.get('clipboard:writeImage')?.(popoutEvent, 'data:image/png;base64,AAAA')
|
||||
).toThrow('Unauthorized clipboard IPC sender')
|
||||
|
||||
expect(clipboardReadImage).not.toHaveBeenCalled()
|
||||
expect(clipboardWriteImage).not.toHaveBeenCalled()
|
||||
expect(clipboardWriteBuffer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still rejects unrelated renderer windows from text clipboard APIs', async () => {
|
||||
isDashboardPopoutRenderer.mockReturnValue(false)
|
||||
|
||||
await expect(handlers.get('clipboard:readText')?.(popoutEvent)).rejects.toThrow(
|
||||
'Unauthorized clipboard IPC sender'
|
||||
)
|
||||
await expect(handlers.get('clipboard:writeText')?.(popoutEvent, 'secret')).rejects.toThrow(
|
||||
'Unauthorized clipboard IPC sender'
|
||||
)
|
||||
|
||||
expect(clipboardReadText).not.toHaveBeenCalled()
|
||||
expect(clipboardWriteText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -127,6 +127,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: callRuntimeEnvironmentMock
|
||||
}))
|
||||
vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false }))
|
||||
|
||||
import {
|
||||
registerClipboardHandlers,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from './clipboard-remote-file-copy'
|
||||
import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload'
|
||||
import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file'
|
||||
import { isDashboardPopoutRenderer } from './dashboard-popout-window'
|
||||
|
||||
let trustedClipboardRendererWebContentsId: number | null = null
|
||||
|
||||
@@ -84,7 +85,7 @@ export function registerClipboardHandlers(store: Store): void {
|
||||
void cleanupExpiredRemoteClipboardFiles()
|
||||
|
||||
ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => {
|
||||
assertTrustedClipboardSender(event)
|
||||
assertTrustedClipboardTextSender(event)
|
||||
return assertClipboardTextWithinLimitWithYield(clipboard.readText(), options)
|
||||
})
|
||||
ipcMain.handle(
|
||||
@@ -155,7 +156,7 @@ export function registerClipboardHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
ipcMain.handle('clipboard:writeText', async (event, text: string) => {
|
||||
assertTrustedClipboardSender(event)
|
||||
assertTrustedClipboardTextSender(event)
|
||||
return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text))
|
||||
})
|
||||
ipcMain.handle('clipboard:writeSelectionText', async (event, text: string) => {
|
||||
@@ -239,6 +240,14 @@ function assertTrustedClipboardSender(event: IpcMainInvokeEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
function assertTrustedClipboardTextSender(event: IpcMainInvokeEvent): void {
|
||||
// Why: terminal copy/paste runs in the exact dashboard popout window, but its
|
||||
// clipboard authority must not extend to image, file, or remote operations.
|
||||
if (!isTrustedClipboardRenderer(event.sender) && !isDashboardPopoutRenderer(event.sender)) {
|
||||
throw new Error('Unauthorized clipboard IPC sender')
|
||||
}
|
||||
}
|
||||
|
||||
function isTrustedClipboardRenderer(sender: WebContents): boolean {
|
||||
if (sender.isDestroyed() || sender.getType() !== 'window') {
|
||||
return false
|
||||
|
||||
@@ -22,7 +22,30 @@ const {
|
||||
destroyed = false
|
||||
minimized = false
|
||||
fullscreen = false
|
||||
webContents = { id: created.length + 1, send: vi.fn(), isDestroyed: () => this.destroyed }
|
||||
focused = false
|
||||
zoomLevel = 0
|
||||
private webContentsHandlers: Record<string, ((...args: unknown[]) => void)[]> = {}
|
||||
webContents = {
|
||||
id: created.length + 1,
|
||||
send: vi.fn(),
|
||||
isDestroyed: () => this.destroyed,
|
||||
session: {
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn()
|
||||
},
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
;(this.webContentsHandlers[event] ||= []).push(cb)
|
||||
},
|
||||
setZoomLevel: vi.fn((level: number) => {
|
||||
this.zoomLevel = level
|
||||
}),
|
||||
getZoomLevel: vi.fn(() => this.zoomLevel)
|
||||
}
|
||||
emitWebContents(event: string, ...args: unknown[]): void {
|
||||
for (const cb of this.webContentsHandlers[event] ?? []) {
|
||||
cb(...args)
|
||||
}
|
||||
}
|
||||
bounds = { x: 100, y: 100, width: 960, height: 720 }
|
||||
focus = vi.fn()
|
||||
show = vi.fn()
|
||||
@@ -64,6 +87,9 @@ const {
|
||||
isDestroyed(): boolean {
|
||||
return this.destroyed
|
||||
}
|
||||
isFocused(): boolean {
|
||||
return this.focused
|
||||
}
|
||||
isMinimized(): boolean {
|
||||
return this.minimized
|
||||
}
|
||||
@@ -111,7 +137,8 @@ vi.mock('./privileged-window-navigation', () => ({
|
||||
import {
|
||||
createOrFocusDashboardPopout,
|
||||
closeDashboardPopout,
|
||||
isDashboardPopoutRenderer
|
||||
isDashboardPopoutRenderer,
|
||||
zoomDashboardPopoutIfFocused
|
||||
} from './dashboard-popout-window'
|
||||
|
||||
type FakeWindow = InstanceType<typeof BrowserWindowMock>
|
||||
@@ -119,8 +146,26 @@ type FakeWindow = InstanceType<typeof BrowserWindowMock>
|
||||
function makeStore(ui: Record<string, unknown> = {}): {
|
||||
getUI: () => Record<string, unknown>
|
||||
updateUI: ReturnType<typeof vi.fn>
|
||||
onUIChanged: ReturnType<typeof vi.fn>
|
||||
emitUIChanged: (next: Record<string, unknown>) => void
|
||||
uiChangeUnsubscribe: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
return { getUI: () => ui, updateUI: vi.fn() }
|
||||
const listeners: ((next: Record<string, unknown>) => void)[] = []
|
||||
const uiChangeUnsubscribe = vi.fn()
|
||||
return {
|
||||
getUI: () => ui,
|
||||
updateUI: vi.fn(),
|
||||
onUIChanged: vi.fn((listener: (next: Record<string, unknown>) => void) => {
|
||||
listeners.push(listener)
|
||||
return uiChangeUnsubscribe
|
||||
}),
|
||||
emitUIChanged: (next) => {
|
||||
for (const listener of listeners) {
|
||||
listener(next)
|
||||
}
|
||||
},
|
||||
uiChangeUnsubscribe
|
||||
}
|
||||
}
|
||||
|
||||
const RENDERER_URL = 'http://localhost:5173'
|
||||
@@ -153,9 +198,18 @@ describe('createOrFocusDashboardPopout', () => {
|
||||
expect(opts.frame).toBeUndefined()
|
||||
expect(opts.backgroundColor).toBe('#0a0a0a') // dark theme mock
|
||||
expect(opts.webPreferences?.sandbox).toBe(true)
|
||||
expect(opts.webPreferences?.partition).toBe('orca-dashboard-popout')
|
||||
expect(opts.webPreferences?.webviewTag).toBe(false)
|
||||
expect(opts.webPreferences?.preload).toMatch(/preload[\\/]index\.js$/)
|
||||
expect(installNavigationPolicyMock).toHaveBeenCalledWith(instances[0].webContents)
|
||||
const { session } = instances[0].webContents
|
||||
expect(session.setPermissionRequestHandler).toHaveBeenCalledTimes(1)
|
||||
expect(session.setPermissionCheckHandler).toHaveBeenCalledTimes(1)
|
||||
|
||||
const permissionCallback = vi.fn()
|
||||
session.setPermissionRequestHandler.mock.calls[0][0](null, 'notifications', permissionCallback)
|
||||
expect(permissionCallback).toHaveBeenCalledWith(false)
|
||||
expect(session.setPermissionCheckHandler.mock.calls[0][0]()).toBe(false)
|
||||
expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith('dashboard:popoutOpenChanged', true)
|
||||
})
|
||||
|
||||
@@ -267,4 +321,135 @@ describe('createOrFocusDashboardPopout', () => {
|
||||
closeDashboardPopout()
|
||||
expect(win.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies the persisted app zoom level on dom-ready', () => {
|
||||
const store = makeStore({ uiZoomLevel: 1.5 })
|
||||
const win = createOrFocusDashboardPopout(store as never) as unknown as FakeWindow
|
||||
expect(win.webContents.setZoomLevel).not.toHaveBeenCalled()
|
||||
win.emitWebContents('dom-ready')
|
||||
expect(win.webContents.setZoomLevel).toHaveBeenCalledWith(1.5)
|
||||
})
|
||||
|
||||
it('follows app zoom changes while open and unsubscribes on close', () => {
|
||||
const store = makeStore({ uiZoomLevel: 0 })
|
||||
const win = createOrFocusDashboardPopout(store as never) as unknown as FakeWindow
|
||||
|
||||
store.emitUIChanged({ uiZoomLevel: 2 })
|
||||
expect(win.webContents.setZoomLevel).toHaveBeenCalledWith(2)
|
||||
|
||||
// Unchanged level: no redundant reapply that would clobber a local zoom.
|
||||
win.webContents.setZoomLevel.mockClear()
|
||||
store.emitUIChanged({ uiZoomLevel: 2 })
|
||||
expect(win.webContents.setZoomLevel).not.toHaveBeenCalled()
|
||||
|
||||
win.emit('closed')
|
||||
expect(store.uiChangeUnsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('zoomDashboardPopoutIfFocused steps the popout zoom only while focused', () => {
|
||||
expect(zoomDashboardPopoutIfFocused('in')).toBe(false)
|
||||
|
||||
const store = makeStore()
|
||||
const win = createOrFocusDashboardPopout(store as never) as unknown as FakeWindow
|
||||
expect(zoomDashboardPopoutIfFocused('in')).toBe(false)
|
||||
expect(win.zoomLevel).toBe(0)
|
||||
|
||||
win.focused = true
|
||||
expect(zoomDashboardPopoutIfFocused('in')).toBe(true)
|
||||
expect(win.zoomLevel).toBe(0.5)
|
||||
expect(zoomDashboardPopoutIfFocused('out')).toBe(true)
|
||||
expect(win.zoomLevel).toBe(0)
|
||||
|
||||
win.zoomLevel = 5
|
||||
expect(zoomDashboardPopoutIfFocused('in')).toBe(true)
|
||||
expect(win.zoomLevel).toBe(5) // clamped at max
|
||||
|
||||
expect(zoomDashboardPopoutIfFocused('reset')).toBe(true)
|
||||
expect(win.zoomLevel).toBe(0)
|
||||
})
|
||||
|
||||
it('handles the zoom-in chord via before-input-event and ignores other keys', () => {
|
||||
const store = makeStore()
|
||||
const win = createOrFocusDashboardPopout(store as never) as unknown as FakeWindow
|
||||
const mod =
|
||||
process.platform === 'darwin'
|
||||
? { meta: true, control: false }
|
||||
: { meta: false, control: true }
|
||||
|
||||
const zoomEvent = { preventDefault: vi.fn() }
|
||||
win.emitWebContents('before-input-event', zoomEvent, {
|
||||
type: 'keyDown',
|
||||
key: '=',
|
||||
code: 'Equal',
|
||||
alt: false,
|
||||
shift: false,
|
||||
...mod
|
||||
})
|
||||
expect(zoomEvent.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(win.zoomLevel).toBe(0.5)
|
||||
|
||||
const plainKeyEvent = { preventDefault: vi.fn() }
|
||||
win.emitWebContents('before-input-event', plainKeyEvent, {
|
||||
type: 'keyDown',
|
||||
key: 'a',
|
||||
code: 'KeyA',
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
control: false
|
||||
})
|
||||
expect(plainKeyEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(win.zoomLevel).toBe(0.5)
|
||||
})
|
||||
|
||||
it('handles mouse-wheel zoom requests outside before-input-event', () => {
|
||||
const win = createOrFocusDashboardPopout(makeStore() as never) as unknown as FakeWindow
|
||||
const event = { preventDefault: vi.fn() }
|
||||
|
||||
win.emitWebContents('zoom-changed', event, 'in')
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(win.zoomLevel).toBe(0.5)
|
||||
|
||||
win.emitWebContents('zoom-changed', event, 'out')
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(2)
|
||||
expect(win.zoomLevel).toBe(0)
|
||||
})
|
||||
|
||||
it('respects zoom keybinding overrides for keyboard and mouse-wheel paths', () => {
|
||||
const win = createOrFocusDashboardPopout(makeStore() as never, undefined, {
|
||||
getKeybindings: () => ({
|
||||
'zoom.in': ['Mod+Y'],
|
||||
'zoom.out': []
|
||||
})
|
||||
}) as unknown as FakeWindow
|
||||
const mod =
|
||||
process.platform === 'darwin'
|
||||
? { meta: true, control: false }
|
||||
: { meta: false, control: true }
|
||||
|
||||
const defaultEvent = { preventDefault: vi.fn() }
|
||||
win.emitWebContents('before-input-event', defaultEvent, {
|
||||
type: 'keyDown',
|
||||
key: '=',
|
||||
code: 'Equal',
|
||||
alt: false,
|
||||
shift: false,
|
||||
...mod
|
||||
})
|
||||
win.emitWebContents('zoom-changed', defaultEvent, 'out')
|
||||
expect(defaultEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(win.zoomLevel).toBe(0)
|
||||
|
||||
const customEvent = { preventDefault: vi.fn() }
|
||||
win.emitWebContents('before-input-event', customEvent, {
|
||||
type: 'keyDown',
|
||||
key: 'y',
|
||||
code: 'KeyY',
|
||||
alt: false,
|
||||
shift: false,
|
||||
...mod
|
||||
})
|
||||
expect(customEvent.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(win.zoomLevel).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,12 +5,21 @@ import type { Store } from '../persistence'
|
||||
import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation'
|
||||
import { sendToTrustedUIRenderer } from '../ipc/ui'
|
||||
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
|
||||
import { stepUIZoomLevel, type UIZoomDirection } from '../../shared/ui-zoom-level'
|
||||
import { nativeZoomCommandMatchesKeybindings } from '../../shared/window-shortcut-policy'
|
||||
import {
|
||||
keybindingMatchesAction,
|
||||
type KeybindingActionId,
|
||||
type KeybindingInput,
|
||||
type KeybindingOverrides
|
||||
} from '../../shared/keybindings'
|
||||
|
||||
const MIN_WIDTH = 480
|
||||
const MIN_HEIGHT = 360
|
||||
const DEFAULT_WIDTH = 960
|
||||
const DEFAULT_HEIGHT = 720
|
||||
const DEFAULT_VIEW = 'kanban'
|
||||
const DASHBOARD_POPOUT_PARTITION = 'orca-dashboard-popout'
|
||||
|
||||
// Why: singleton — the dashboard is a companion surface, so a second "Pop Out"
|
||||
// request focuses the existing window rather than spawning duplicates.
|
||||
@@ -30,6 +39,47 @@ export function isDashboardPopoutRenderer(sender: WebContents): boolean {
|
||||
return getDashboardPopoutWindow()?.webContents === sender
|
||||
}
|
||||
|
||||
function zoomDashboardPopout(window: BrowserWindow, direction: UIZoomDirection): void {
|
||||
const webContents = window.webContents
|
||||
webContents.setZoomLevel(stepUIZoomLevel(webContents.getZoomLevel(), direction))
|
||||
}
|
||||
|
||||
const ZOOM_SHORTCUTS: readonly [KeybindingActionId, UIZoomDirection][] = [
|
||||
['zoom.in', 'in'],
|
||||
['zoom.out', 'out'],
|
||||
['zoom.reset', 'reset']
|
||||
]
|
||||
|
||||
function resolveZoomShortcut(
|
||||
input: KeybindingInput,
|
||||
keybindings: KeybindingOverrides | undefined
|
||||
): UIZoomDirection | null {
|
||||
// Why: this runs on every keydown; avoid scanning unrelated window shortcuts in the typing path.
|
||||
for (const [actionId, direction] of ZOOM_SHORTCUTS) {
|
||||
if (
|
||||
keybindingMatchesAction(actionId, input, process.platform, keybindings, { context: 'app' })
|
||||
) {
|
||||
return direction
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a zoom step to the pop-out when it is the focused window. Returns false
|
||||
* when the pop-out is closed or unfocused so the caller can route the action to
|
||||
* the main window instead — the menu's zoom items must act on the window the
|
||||
* user is looking at.
|
||||
*/
|
||||
export function zoomDashboardPopoutIfFocused(direction: UIZoomDirection): boolean {
|
||||
const popout = getDashboardPopoutWindow()
|
||||
if (!popout || !popout.isFocused()) {
|
||||
return false
|
||||
}
|
||||
zoomDashboardPopout(popout, direction)
|
||||
return true
|
||||
}
|
||||
|
||||
// In-process listeners (the dashboard relay clears its cached snapshot on close).
|
||||
const popoutOpenListeners = new Set<(open: boolean) => void>()
|
||||
|
||||
@@ -88,7 +138,8 @@ function resolveRestoredBounds(store: Store | null): {
|
||||
*/
|
||||
export function createOrFocusDashboardPopout(
|
||||
store: Store | null,
|
||||
view: string = DEFAULT_VIEW
|
||||
view: string = DEFAULT_VIEW,
|
||||
options: { getKeybindings?: () => KeybindingOverrides | undefined } = {}
|
||||
): BrowserWindow {
|
||||
if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) {
|
||||
if (dashboardPopoutWindow.isMinimized()) {
|
||||
@@ -118,15 +169,71 @@ export function createOrFocusDashboardPopout(
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: true,
|
||||
// Why: Chromium shares zoom by origin; a separate in-memory session keeps pop-out zoom window-local.
|
||||
partition: DASHBOARD_POPOUT_PARTITION,
|
||||
// Why: the dashboard is plain DOM — no <webview> guests — so keep the
|
||||
// guest-embedding surface off for this window.
|
||||
webviewTag: false
|
||||
}
|
||||
})
|
||||
installPrivilegedWindowNavigationPolicy(window.webContents)
|
||||
// Why: isolated sessions do not inherit the main session's deny-by-default permission policy.
|
||||
window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) =>
|
||||
callback(false)
|
||||
)
|
||||
window.webContents.session.setPermissionCheckHandler(() => false)
|
||||
dashboardPopoutWindow = window
|
||||
broadcastPopoutOpenChanged(true)
|
||||
|
||||
// Why: uiZoomLevel is the app-wide UI zoom; without this the pop-out always
|
||||
// renders at 100% while the main window honors the persisted level.
|
||||
window.webContents.on('dom-ready', () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.setZoomLevel(store?.getUI().uiZoomLevel ?? 0)
|
||||
}
|
||||
})
|
||||
// Follow app-zoom changes made while the pop-out is open (main-window zoom,
|
||||
// settings control, mobile ui.set). Compare against the last followed value,
|
||||
// not the live webContents level, so a window-local zoom via the menu/chords
|
||||
// is not snapped back until the app-wide level actually changes.
|
||||
let lastFollowedZoomLevel = store?.getUI().uiZoomLevel ?? 0
|
||||
const unsubscribeUIChanged = store?.onUIChanged((ui) => {
|
||||
const level = ui.uiZoomLevel ?? 0
|
||||
if (level === lastFollowedZoomLevel) {
|
||||
return
|
||||
}
|
||||
lastFollowedZoomLevel = level
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.setZoomLevel(level)
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the pop-out has no renderer-side shortcut plumbing; resolve only the
|
||||
// zoom chords here and let every other key fall through untouched.
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
const direction = resolveZoomShortcut(input, options.getKeybindings?.())
|
||||
if (direction) {
|
||||
event.preventDefault()
|
||||
zoomDashboardPopout(window, direction)
|
||||
}
|
||||
})
|
||||
|
||||
window.webContents.on('zoom-changed', (event, direction) => {
|
||||
// Why: Electron reports Ctrl/Cmd+wheel zoom outside the keyboard input path.
|
||||
if (
|
||||
(direction === 'in' || direction === 'out') &&
|
||||
nativeZoomCommandMatchesKeybindings(direction, process.platform, options.getKeybindings?.(), {
|
||||
context: 'app'
|
||||
})
|
||||
) {
|
||||
event.preventDefault()
|
||||
zoomDashboardPopout(window, direction)
|
||||
}
|
||||
})
|
||||
|
||||
window.once('ready-to-show', () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.show()
|
||||
@@ -169,6 +276,7 @@ export function createOrFocusDashboardPopout(
|
||||
|
||||
window.on('closed', () => {
|
||||
app.removeListener('before-quit', freezeBounds)
|
||||
unsubscribeUIChanged?.()
|
||||
if (dashboardPopoutWindow === window) {
|
||||
dashboardPopoutWindow = null
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
detectAgents: vi.fn(),
|
||||
launchContinuation: vi.fn(),
|
||||
settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] }
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) => selector({ settings: mocks.settings })
|
||||
}))
|
||||
vi.mock('@/lib/launch-agent-session-continuation', () => ({
|
||||
detectAgentSessionContinuationAgents: mocks.detectAgents,
|
||||
launchAgentSessionContinuation: mocks.launchContinuation
|
||||
}))
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }],
|
||||
getAgentLabel: () => 'Codex'
|
||||
}))
|
||||
vi.mock('@/components/agent/AgentCombobox', () => ({
|
||||
default: ({ value }: { value: string | null }) =>
|
||||
React.createElement('div', { 'data-agent': value ?? '' })
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
DialogContent: ({ children }: { children?: ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
DialogDescription: ({ children }: { children?: ReactNode }) =>
|
||||
React.createElement('p', null, children),
|
||||
DialogFooter: ({ children }: { children?: ReactNode }) =>
|
||||
React.createElement('footer', null, children),
|
||||
DialogHeader: ({ children }: { children?: ReactNode }) =>
|
||||
React.createElement('header', null, children),
|
||||
DialogTitle: ({ children }: { children?: ReactNode }) => React.createElement('h2', null, children)
|
||||
}))
|
||||
vi.mock('@/components/ui/select', () => ({
|
||||
Select: ({ children }: { children?: ReactNode }) => React.createElement('div', null, children),
|
||||
SelectContent: ({ children }: { children?: ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
SelectItem: ({ children }: { children?: ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
SelectTrigger: ({ children }: { children?: ReactNode }) =>
|
||||
React.createElement('button', null, children),
|
||||
SelectValue: () => React.createElement('span')
|
||||
}))
|
||||
|
||||
import { AgentSessionContinuationDialog } from './AgentSessionContinuationDialog'
|
||||
|
||||
function request(worktreeId: string): AgentSessionContinuationRequest {
|
||||
return {
|
||||
source: { capturedText: 'previous session', sourceAgent: 'codex' },
|
||||
worktreeId,
|
||||
workspacePath: '/repo',
|
||||
launchSource: 'sidebar'
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentSessionContinuationDialog', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
;(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.clearAllMocks()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('clears a prior detection failure while detecting a new request', async () => {
|
||||
let resolveSecond: (agents: ['codex']) => void = () => {}
|
||||
mocks.detectAgents.mockRejectedValueOnce(new Error('offline')).mockReturnValueOnce(
|
||||
new Promise<['codex']>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AgentSessionContinuationDialog open request={request('wt-1')} onOpenChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
await vi.waitFor(() => expect(container.textContent).toContain('Could not detect Agents'))
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<AgentSessionContinuationDialog open request={request('wt-2')} onOpenChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
expect(container.textContent).toContain('Detecting Agents')
|
||||
expect(container.textContent).not.toContain('Could not detect Agents')
|
||||
|
||||
await act(async () => resolveSecond(['codex']))
|
||||
await vi.waitFor(() => expect(container.querySelector('[data-agent="codex"]')).not.toBeNull())
|
||||
})
|
||||
})
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, MessageSquarePlus } from 'lucide-react'
|
||||
import AgentCombobox from '@/components/agent/AgentCombobox'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getAgentCatalog, getAgentLabel } from '@/lib/agent-catalog'
|
||||
import {
|
||||
buildAgentSessionContinuationPrompt,
|
||||
hasFullAgentSessionContext,
|
||||
type AgentSessionContinuationContextMode,
|
||||
type AgentSessionContinuationRequest
|
||||
} from '@/lib/agent-session-continuation'
|
||||
import {
|
||||
detectAgentSessionContinuationAgents,
|
||||
launchAgentSessionContinuation
|
||||
} from '@/lib/launch-agent-session-continuation'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { chooseInitialContinuationAgent } from './agent-session-continuation-selection'
|
||||
|
||||
type AgentSessionContinuationDialogProps = {
|
||||
open: boolean
|
||||
request: AgentSessionContinuationRequest | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const EMPTY_DISABLED_AGENTS: TuiAgent[] = []
|
||||
|
||||
export function AgentSessionContinuationDialog({
|
||||
open,
|
||||
request,
|
||||
onOpenChange
|
||||
}: AgentSessionContinuationDialogProps): React.JSX.Element {
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const [detectedAgents, setDetectedAgents] = useState<TuiAgent[]>([])
|
||||
const [selectedAgent, setSelectedAgent] = useState<TuiAgent | null>(null)
|
||||
const [contextMode, setContextMode] = useState<AgentSessionContinuationContextMode>('focused')
|
||||
const [detecting, setDetecting] = useState(true)
|
||||
const [detectionFailed, setDetectionFailed] = useState(false)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [showStarting, setShowStarting] = useState(false)
|
||||
const disabledAgents = settings?.disabledTuiAgents ?? EMPTY_DISABLED_AGENTS
|
||||
|
||||
const agents = useMemo(
|
||||
() =>
|
||||
getAgentCatalog().filter(
|
||||
(agent) => detectedAgents.includes(agent.id) && isTuiAgentEnabled(agent.id, disabledAgents)
|
||||
),
|
||||
[detectedAgents, disabledAgents]
|
||||
)
|
||||
const hasFullContext = request ? hasFullAgentSessionContext(request.source) : false
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !request) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setDetecting(true)
|
||||
setDetectionFailed(false)
|
||||
setDetectedAgents([])
|
||||
setSelectedAgent(null)
|
||||
setContextMode('focused')
|
||||
void detectAgentSessionContinuationAgents(request.worktreeId)
|
||||
.then((detected) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
const enabled = detected.filter((agent) => isTuiAgentEnabled(agent, disabledAgents))
|
||||
setDetectedAgents(enabled)
|
||||
setSelectedAgent(
|
||||
chooseInitialContinuationAgent({
|
||||
availableAgents: enabled,
|
||||
sourceAgent: request.source.sourceAgent,
|
||||
defaultAgent: settings?.defaultTuiAgent
|
||||
})
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Agent detection failed for continuation dialog', error)
|
||||
if (!cancelled) {
|
||||
setDetectedAgents([])
|
||||
setSelectedAgent(null)
|
||||
setDetectionFailed(true)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setDetecting(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [disabledAgents, open, request, settings?.defaultTuiAgent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!starting) {
|
||||
setShowStarting(false)
|
||||
return
|
||||
}
|
||||
// Why: local launches are often instant; defer the spinner so fast paths do not flicker.
|
||||
const timer = window.setTimeout(() => setShowStarting(true), 200)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [starting])
|
||||
|
||||
const handleStart = async (): Promise<void> => {
|
||||
if (!request || !selectedAgent || starting) {
|
||||
return
|
||||
}
|
||||
const prompt = buildAgentSessionContinuationPrompt(request.source, contextMode)
|
||||
if (!prompt) {
|
||||
return
|
||||
}
|
||||
setStarting(true)
|
||||
const launched = await launchAgentSessionContinuation({
|
||||
agent: selectedAgent,
|
||||
prompt,
|
||||
worktreeId: request.worktreeId,
|
||||
groupId: request.groupId,
|
||||
workspacePath: request.workspacePath,
|
||||
initialCwd: request.initialCwd,
|
||||
launchSource: request.launchSource
|
||||
})
|
||||
setStarting(false)
|
||||
if (launched) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sourceName = request?.source.sourceTitle?.trim()
|
||||
const sourceAgentLabel = request?.source.sourceAgent
|
||||
? getAgentLabel(request.source.sourceAgent)
|
||||
: null
|
||||
const startDisabled = detecting || starting || agents.length === 0 || !selectedAgent
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!starting) {
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="min-w-0 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<MessageSquarePlus className="size-4" />
|
||||
{translate(
|
||||
'components.agentSessionContinuation.dialogTitle',
|
||||
'Continue in New Session'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{translate(
|
||||
'components.agentSessionContinuation.dialogDescription',
|
||||
'Start a fresh Agent session from this stopping point. The original session stays unchanged.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="min-w-0 rounded-md border border-border bg-muted/30 px-3 py-2">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{sourceName ||
|
||||
translate('components.agentSessionContinuation.untitledSession', 'Current session')}
|
||||
</div>
|
||||
{sourceAgentLabel ? (
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'components.agentSessionContinuation.originalAgent',
|
||||
'Original Agent: {{agent}}',
|
||||
{ agent: sourceAgentLabel }
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<label className="text-xs font-medium">
|
||||
{translate('components.agentSessionContinuation.agent', 'Agent')}
|
||||
</label>
|
||||
<AgentCombobox
|
||||
agents={agents}
|
||||
value={selectedAgent}
|
||||
onValueChange={setSelectedAgent}
|
||||
allowBlankTerminal={false}
|
||||
allowNarrowTrigger
|
||||
emptyLabel={translate(
|
||||
'components.agentSessionContinuation.selectAgent',
|
||||
'Select an Agent'
|
||||
)}
|
||||
triggerClassName="min-w-0 w-full"
|
||||
/>
|
||||
{detecting ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'components.agentSessionContinuation.detectingAgents',
|
||||
'Detecting Agents on this workspace host…'
|
||||
)}
|
||||
</p>
|
||||
) : detectionFailed ? (
|
||||
<p className="text-[11px] text-destructive">
|
||||
{translate(
|
||||
'components.agentSessionContinuation.detectionFailed',
|
||||
'Could not detect Agents on this workspace host.'
|
||||
)}
|
||||
</p>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'components.agentSessionContinuation.noAgents',
|
||||
'No enabled Agents were detected on this workspace host.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<label className="text-xs font-medium">
|
||||
{translate('components.agentSessionContinuation.context', 'Context')}
|
||||
</label>
|
||||
<Select
|
||||
value={contextMode}
|
||||
onValueChange={(value) =>
|
||||
setContextMode(value as AgentSessionContinuationContextMode)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-0 w-full" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="focused">
|
||||
{translate(
|
||||
'components.agentSessionContinuation.modeFocused',
|
||||
'Focused handoff (Recommended)'
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="full" disabled={!hasFullContext}>
|
||||
{translate(
|
||||
'components.agentSessionContinuation.modeFull',
|
||||
'Full session transcript'
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||
{contextMode === 'focused'
|
||||
? translate(
|
||||
'components.agentSessionContinuation.modeFocusedDescription',
|
||||
'Uses the latest status and current workspace, reading older transcript details only when needed.'
|
||||
)
|
||||
: translate(
|
||||
'components.agentSessionContinuation.modeFullDescription',
|
||||
'Asks the new Agent to read the complete saved session before continuing. This can take longer and use significant context, plan usage, or API credits.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{request?.initialCwd ? (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{translate('components.agentSessionContinuation.startsIn', 'Starts in:')}{' '}
|
||||
<span className="break-all font-mono text-foreground/80">{request.initialCwd}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={starting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{translate('components.native-chat.question.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
autoFocus
|
||||
disabled={startDisabled}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
{showStarting ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{starting
|
||||
? translate('components.agentSessionContinuation.starting', 'Starting…')
|
||||
: translate('components.agentSessionContinuation.startSession', 'Start New Session')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { chooseInitialContinuationAgent } from './agent-session-continuation-selection'
|
||||
|
||||
describe('chooseInitialContinuationAgent', () => {
|
||||
it('keeps the source Agent when it is available', () => {
|
||||
expect(
|
||||
chooseInitialContinuationAgent({
|
||||
availableAgents: ['codex', 'claude'],
|
||||
sourceAgent: 'claude',
|
||||
defaultAgent: 'codex'
|
||||
})
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('falls back to the saved default and then the first available Agent', () => {
|
||||
expect(
|
||||
chooseInitialContinuationAgent({
|
||||
availableAgents: ['codex', 'claude'],
|
||||
sourceAgent: 'gemini',
|
||||
defaultAgent: 'claude'
|
||||
})
|
||||
).toBe('claude')
|
||||
expect(
|
||||
chooseInitialContinuationAgent({
|
||||
availableAgents: ['codex'],
|
||||
sourceAgent: null,
|
||||
defaultAgent: 'blank'
|
||||
})
|
||||
).toBe('codex')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user