mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/resolve-11446-i18n-ctx-a963
# Conflicts: # mobile/src/session/use-mobile-native-chat-message-send.ts # src/main/daemon/daemon-pty-router.ts
This commit is contained in:
@@ -3087,15 +3087,16 @@
|
||||
"providers": ["paired-runtime", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["paired-runtime"],
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8652",
|
||||
"https://github.com/stablyai/orca/pull/10625"
|
||||
],
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.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/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
@@ -3124,6 +3125,7 @@
|
||||
"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
|
||||
"src/renderer/src/runtime/runtime-client-events.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
"tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts",
|
||||
"tests/e2e/paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts",
|
||||
@@ -3152,7 +3154,8 @@
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
|
||||
"assertions": [
|
||||
"ordinary-parks paired terminals and restores authoritative host scrollback"
|
||||
"ordinary-parks paired terminals and restores authoritative host scrollback",
|
||||
"client recovery completes while every target terminal remains unmounted in the host renderer"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3276,7 +3279,7 @@
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.5,
|
||||
"summary": "Six real paired worktrees and host PTYs ordinary-parked five client xterms with the lossy budget disabled, released at least 55% of staged cells within heap and timer-lag budgets, then restored requested row 4,000, parked-time output, and live I/O on the original PTY."
|
||||
"summary": "Six real paired worktrees and host PTYs ordinary-parked five client xterms with the lossy budget disabled, released at least 55% of staged cells within heap and timer-lag budgets, then restored requested row 4,000, parked-time output, and live I/O on the original PTY while every target terminal remained unmounted in the host renderer."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
@@ -3304,6 +3307,15 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 18.6,
|
||||
"summary": "A host without terminal.paired-parking.v1 preserved the existing lossy limit-one fallback, restored retained output, and continued PTY input/output."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.16,
|
||||
"summary": "All 27 encrypted shared-control tests passed. The new oracle was red on rc.1-equivalent behavior with the exact Refreshing remote runtime control transport error, then passed with one replacement connection, one delivered host RPC, and zero retained pending bytes."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -4301,7 +4313,7 @@
|
||||
"providers": ["ssh", "remote-runtime", "wsl"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["ssh", "remote-runtime"],
|
||||
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. 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. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.",
|
||||
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. 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. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6951",
|
||||
"https://github.com/stablyai/orca/pull/6955",
|
||||
@@ -4309,8 +4321,8 @@
|
||||
"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. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. 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, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. Tests also assert one unsubscribe per remote-runtime 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, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.",
|
||||
"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. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. 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. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. 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, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime 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, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot",
|
||||
"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",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"../src/main/agent-hooks/local-agent-cli-presence.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/ipc/local-agent-install-dir-detection.ts",
|
||||
"../src/main/ipc/tui-agent-detection-commands.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
|
||||
@@ -729,6 +729,69 @@ deliberately. The post-upgrade binary and version record are retained in
|
||||
artifacts and remove them according to your retention policy after the rollback
|
||||
is resolved.
|
||||
|
||||
## Installing Agent Skills Without A Desktop
|
||||
|
||||
Orca's agent skills (CLI usage, orchestration, computer use, etc.) are normally
|
||||
installed from Orca Settings, which pre-fills an `npx skills add ... --global`
|
||||
command in a terminal for you to run. A headless host has no Settings UI, so
|
||||
use `orca skills install` instead:
|
||||
|
||||
```bash
|
||||
orca skills install # list installable skills
|
||||
orca skills install --skill orca-cli --skill orchestration # install globally (default)
|
||||
orca skills install --skill orca-cli --local # install into the current project only
|
||||
orca skills install --all # install every bundled skill
|
||||
orca skills install --all --dry-run # print the npx command without running it
|
||||
```
|
||||
|
||||
This resolves the same `npx skills add <repo> --skill <name> ...` command
|
||||
Settings would show you (adding `--global` unless `--local` is passed), then
|
||||
runs it and forwards its output and exit code. It requires `node`/`npx` on the
|
||||
host; it does not need a running Orca runtime.
|
||||
|
||||
Unlike the command Settings shows, the spawned one adds `npx --yes` and `-y`.
|
||||
Without them the `skills` CLI opens an interactive agent picker and blocks
|
||||
forever on any allocated TTY — which includes a normal `ssh` session. Use
|
||||
`--dry-run` to see the exact command that will run.
|
||||
|
||||
Settings keeps that picker deliberately, because choosing which agents get a
|
||||
skill is a real decision. A headless run cannot answer it, so instead of dropping
|
||||
the choice Orca makes it explicitly: it passes an `--agent` list built from the
|
||||
coding agents it detects on the host, plus the shared `.agents/skills` directory
|
||||
it reads itself. Left to decide on its own with no agent detected, the `skills`
|
||||
CLI installs into all ~75 agents it knows and leaves a config directory for each.
|
||||
Override the targets yourself, or narrow to the shared directory alone:
|
||||
|
||||
```bash
|
||||
orca skills install --skill orca-cli --agent claude-code,codex
|
||||
orca skills install --skill orca-cli --agent universal
|
||||
```
|
||||
|
||||
If Orca detects no agent at all, `orca skills install` stops and asks for
|
||||
`--agent` rather than guessing.
|
||||
|
||||
To refresh already-installed skills, `orca skills update` mirrors the same
|
||||
selection flags (`--skill`, `--all`, `--local`, `--dry-run`) and resolves to
|
||||
`npx skills update <names...>` with a matching scope flag — `--global`, or
|
||||
`--project` when you pass `--local`:
|
||||
|
||||
```bash
|
||||
orca skills update --all # update every bundled skill globally
|
||||
orca skills update --skill orca-cli --dry-run # print the npx command without running it
|
||||
```
|
||||
|
||||
`orca skills update` only refreshes skills that are already installed — it exits
|
||||
0 without doing anything for a skill that is missing, so install it first. More
|
||||
generally, a 0 exit means the `skills` CLI ran without erroring, not that it
|
||||
wrote anything; read its output to confirm what changed.
|
||||
|
||||
`--json` covers the skill listing and `--dry-run`. A real run streams the
|
||||
`skills` CLI's own non-JSON output and rejects `--json`.
|
||||
|
||||
Both commands install onto the machine that runs them. In an Orca SSH workspace
|
||||
or the WSL bridge the `orca` shim forwards commands to the Orca host, so they
|
||||
refuse to run there and print the command to run on the machine you want.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `dlopen(): error loading libfuse.so.2`: install `libfuse2`.
|
||||
|
||||
@@ -3682,6 +3682,7 @@ export default function SessionScreen() {
|
||||
getActiveWorktreeConnectionId,
|
||||
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
|
||||
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
|
||||
readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft,
|
||||
showToast,
|
||||
onNativeChatSendError: nativeChatSendError.show,
|
||||
onSuccess: triggerSelection,
|
||||
|
||||
@@ -28,6 +28,7 @@ export type MobileSessionTab =
|
||||
launchAgent?: TuiAgent
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string
|
||||
launchDraftCreatedAt?: number
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export type MobileNativeChatTab = {
|
||||
agentStatus?: AgentStatusEntry | null
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string
|
||||
launchDraftCreatedAt?: number
|
||||
}
|
||||
|
||||
/** Resolve a session tab to the transcript identity native chat needs, or
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
function sendResult(accepted: boolean, id = 'send'): RpcSuccess {
|
||||
return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
|
||||
@@ -101,3 +102,52 @@ describe('pasteMobileNativeChatImagePaths', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearing a parked multi-line launch draft before the image paste', () => {
|
||||
it('leads with the caller-sized burst instead of one Ctrl+U', async () => {
|
||||
// One Ctrl+U kills only the LAST line, so the draft's earlier lines would
|
||||
// survive and ride along with the image as part of the prompt body.
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true)])
|
||||
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
|
||||
|
||||
await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png'],
|
||||
clearInput
|
||||
})
|
||||
|
||||
expect(client.calls[0]?.params.text).toBe(clearInput)
|
||||
expect(client.calls[0]?.params.text).not.toBe('\x15')
|
||||
})
|
||||
|
||||
it('clears once, before the paste — never between or after the image writes', async () => {
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
|
||||
const clearInput = buildAgentTuiClearInputForText('a\nb\nc')
|
||||
|
||||
await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
|
||||
clearInput
|
||||
})
|
||||
|
||||
expect(client.calls.filter((call) => call.params.text === clearInput)).toHaveLength(1)
|
||||
expect(client.calls[0]?.params.text).toBe(clearInput)
|
||||
})
|
||||
|
||||
it('falls back to a single Ctrl+U when no draft is parked', async () => {
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true)])
|
||||
|
||||
await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png']
|
||||
})
|
||||
|
||||
expect(client.calls[0]?.params.text).toBe('\x15')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,10 @@ type PasteImagesArgs = {
|
||||
/** Budget shared with the rest of the user action (the text body that follows, or
|
||||
* the send this is healing for). Omit to open a fresh one for this paste alone. */
|
||||
readonly deadline?: number
|
||||
/** Bytes for the leading clear. Defaults to a single Ctrl+U, which clears only
|
||||
* ONE logical line — callers holding a parked multi-line launch draft must
|
||||
* pass a burst, or its earlier lines survive and glue onto the message. */
|
||||
readonly clearInput?: string
|
||||
}
|
||||
|
||||
/** Clears the agent's unsubmitted input line, then pastes each uploaded image
|
||||
@@ -38,7 +42,8 @@ export async function pasteMobileNativeChatImagePaths({
|
||||
terminal,
|
||||
deviceToken,
|
||||
imagePaths,
|
||||
deadline: sharedDeadline
|
||||
deadline: sharedDeadline,
|
||||
clearInput
|
||||
}: PasteImagesArgs): Promise<boolean> {
|
||||
const mobileClient: MobileTerminalClient | null = deviceToken
|
||||
? { id: deviceToken, type: 'mobile' }
|
||||
@@ -49,7 +54,7 @@ export async function pasteMobileNativeChatImagePaths({
|
||||
// once and let each write draw from what's left.
|
||||
const deadline = sharedDeadline ?? openMobileNativeChatSendBudget()
|
||||
for (const text of [
|
||||
MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
...imagePaths.map(buildMobileImagePastePayload)
|
||||
]) {
|
||||
const remainingMs = deadline - Date.now()
|
||||
|
||||
@@ -5,9 +5,11 @@ import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-clien
|
||||
import {
|
||||
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS,
|
||||
openMobileNativeChatSendBudget,
|
||||
clearMobileNativeChatInput,
|
||||
sendMobileNativeChatMessage,
|
||||
sendMobileNativeChatMessageWithOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
function clientWithResponse(response: unknown): RpcClient {
|
||||
return {
|
||||
@@ -29,6 +31,7 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
|
||||
mobileClient: { id: 'device', type: 'mobile' }
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
@@ -38,6 +41,7 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
enter: true,
|
||||
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
|
||||
client: { id: 'device', type: 'mobile' }
|
||||
},
|
||||
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
|
||||
@@ -285,3 +289,83 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMobileNativeChatInput', () => {
|
||||
const accepted = {
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}
|
||||
const params = (client: RpcClient) =>
|
||||
vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string; enter: boolean }
|
||||
|
||||
it('writes the burst as its OWN non-submitting write', async () => {
|
||||
// Bundling the burst into the body write reached the agent as LITERAL Ctrl+U
|
||||
// text and the parked draft concatenated (observed live).
|
||||
const client = clientWithResponse(accepted)
|
||||
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
|
||||
await expect(
|
||||
clearMobileNativeChatInput({ client, terminal: 'term', clearInput })
|
||||
).resolves.toBe(true)
|
||||
expect(params(client)).toMatchObject({ text: clearInput, enter: false })
|
||||
})
|
||||
|
||||
it('reports failure when the host rejects the clear', async () => {
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: false } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
await expect(
|
||||
clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' })
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to start an underfunded clear rather than half-clearing', async () => {
|
||||
const client = clientWithResponse(accepted)
|
||||
await expect(
|
||||
clearMobileNativeChatInput({
|
||||
client,
|
||||
terminal: 'term',
|
||||
clearInput: '\x15',
|
||||
deadline: Date.now() + 10
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
expect(client.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the body write never carries a multi-line burst', () => {
|
||||
const accepted = {
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}
|
||||
const sentText = (client: RpcClient): string =>
|
||||
(vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string }).text
|
||||
|
||||
it('still prefixes only a single Ctrl+U when asked to clear first', async () => {
|
||||
const client = clientWithResponse(accepted)
|
||||
await sendMobileNativeChatMessage({
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'hello',
|
||||
clearInputFirst: true
|
||||
})
|
||||
expect(sentText(client)).toBe('\x15hello')
|
||||
})
|
||||
|
||||
it('never prefixes a clear when the caller already pasted (image sends)', async () => {
|
||||
const client = clientWithResponse(accepted)
|
||||
await sendMobileNativeChatMessage({
|
||||
client,
|
||||
terminal: 'term',
|
||||
text: 'caption',
|
||||
clearInputFirst: false
|
||||
})
|
||||
expect(sentText(client)).toBe('caption')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,11 @@ type MobileTerminalClient = {
|
||||
// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the
|
||||
// same byte before its body), so a launch-context prefill parked there cannot
|
||||
// concatenate with a mobile chat message. The host writes text bytes verbatim.
|
||||
//
|
||||
// One Ctrl+U clears ONE logical line, which is all this prefix can do. A parked
|
||||
// launch draft is routinely multi-line (every Linear block is); callers that know
|
||||
// one is parked must call clearMobileNativeChatInput FIRST — see
|
||||
// src/shared/agent-tui-input-clear.ts for the measured 2N-1 law.
|
||||
const CLEAR_UNSUBMITTED_INPUT = '\x15'
|
||||
|
||||
type MobileNativeChatSendArgs = {
|
||||
@@ -19,6 +24,8 @@ type MobileNativeChatSendArgs = {
|
||||
text: string
|
||||
enter?: boolean
|
||||
clearInputFirst?: boolean
|
||||
/** Exact host launch draft this submitting write resolves when accepted. */
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
mobileClient?: MobileTerminalClient
|
||||
/** Shared budget for a whole user action (heal → paste → text, or one selector's
|
||||
* keystroke sequence). Omit to give this write its own full budget. */
|
||||
@@ -59,6 +66,7 @@ export async function sendMobileNativeChatMessageWithOutcome(
|
||||
terminal: args.terminal,
|
||||
text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text,
|
||||
enter: args.enter ?? true,
|
||||
...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}),
|
||||
...(args.mobileClient ? { client: args.mobileClient } : {})
|
||||
},
|
||||
// The budget covers this whole write, reconnect wait included — a chat send
|
||||
@@ -84,3 +92,42 @@ export async function sendMobileNativeChatMessage(
|
||||
): Promise<boolean> {
|
||||
return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted'
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the agent's input line as its OWN write, before any body.
|
||||
*
|
||||
* Why not prefix it onto the body write: a multi-line clear burst bundled into
|
||||
* the same `terminal.send` as the text reached the agent as LITERAL Ctrl+U
|
||||
* characters — the draft survived and the burst landed in the middle of the
|
||||
* message (observed live: draft + 21 literal \x15 + body). A standalone write is
|
||||
* the shape the image paste has always used, and it clears as intended.
|
||||
*/
|
||||
export async function clearMobileNativeChatInput(args: {
|
||||
client: RpcClient
|
||||
terminal: string
|
||||
clearInput: string
|
||||
mobileClient?: MobileTerminalClient
|
||||
deadline?: number
|
||||
}): Promise<boolean> {
|
||||
const timeoutMs =
|
||||
args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now()
|
||||
if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const response = await args.client.sendRequest(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: args.terminal,
|
||||
text: args.clearInput,
|
||||
enter: false,
|
||||
...(args.mobileClient ? { client: args.mobileClient } : {})
|
||||
},
|
||||
{ timeoutMs, budgetSpansConnect: true }
|
||||
)
|
||||
return isTerminalSendRpcAccepted(response)
|
||||
} catch {
|
||||
// A failed clear must not send the body on top of an uncleared line.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,12 +102,14 @@ describe('mobile terminal records', () => {
|
||||
}
|
||||
const seeded: MobileTerminalSessionTab = {
|
||||
...base,
|
||||
launchDraft: 'https://github.com/o/r/issues/12'
|
||||
launchDraft: 'https://github.com/o/r/issues/12',
|
||||
launchDraftCreatedAt: 1
|
||||
}
|
||||
|
||||
expect(mobileSessionTabsEqual([base], [seeded])).toBe(false)
|
||||
expect(mobileSessionTabsEqual([seeded], [base])).toBe(false)
|
||||
expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true)
|
||||
expect(mobileSessionTabsEqual([seeded], [{ ...seeded, launchDraftCreatedAt: 2 }])).toBe(false)
|
||||
})
|
||||
|
||||
it('treats terminal agent-status changes as session-tab changes', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export type MobileTerminalSessionTab = {
|
||||
agentStatus?: AgentStatusEntry | null
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string
|
||||
launchDraftCreatedAt?: number
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
isActive: boolean
|
||||
}
|
||||
@@ -90,6 +91,7 @@ function mobileSessionTabEqual(
|
||||
// A frame whose only delta is the launch draft appearing or retracting
|
||||
// still has to reach the chat composer.
|
||||
a.launchDraft === b.launchDraft &&
|
||||
a.launchDraftCreatedAt === b.launchDraftCreatedAt &&
|
||||
JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) &&
|
||||
JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null)
|
||||
)
|
||||
|
||||
@@ -35,6 +35,8 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
|
||||
setComposerText: vi.fn(),
|
||||
pending: [],
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft: () => null,
|
||||
readSeededLaunchDraftSeed: () => null,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
@@ -327,6 +329,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
|
||||
terminal: 'term-1',
|
||||
launchAgent: 'claude',
|
||||
launchDraft: 'https://github.com/o/r/issues/12',
|
||||
launchDraftCreatedAt: 7,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
@@ -385,6 +388,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
|
||||
expect(draftsArgs.at(-1)).toMatchObject({
|
||||
tabId: 'tab-1',
|
||||
launchDraft: 'https://github.com/o/r/issues/12',
|
||||
launchDraftCreatedAt: 7,
|
||||
chatActive: true,
|
||||
transcriptLoading: false
|
||||
})
|
||||
|
||||
@@ -73,6 +73,9 @@ export type MobileNativeChatController = {
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text still parked on the agent's TUI input line, or null.
|
||||
* Image sends read it to size their leading clear (one Ctrl+U per line). */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
}
|
||||
|
||||
/** Owns mobile native-chat state and teardown outside the already dense session
|
||||
@@ -134,6 +137,8 @@ export function useMobileNativeChatController(args: {
|
||||
setComposerText: setChatComposerText,
|
||||
pending: chatPending,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
@@ -145,6 +150,7 @@ export function useMobileNativeChatController(args: {
|
||||
sessionId: activeChatSessionId,
|
||||
messages: nativeChatSession.messages,
|
||||
launchDraft: activeSessionTab?.launchDraft ?? null,
|
||||
launchDraftCreatedAt: activeSessionTab?.launchDraftCreatedAt ?? null,
|
||||
// Why: pass the raw draft plus this flag rather than nulling it off-chat —
|
||||
// a null is indistinguishable from a host retraction, and peeking at the
|
||||
// terminal view would permanently decline the prefill.
|
||||
@@ -243,6 +249,7 @@ export function useMobileNativeChatController(args: {
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
@@ -278,6 +285,7 @@ export function useMobileNativeChatController(args: {
|
||||
loadNativeChatFiles,
|
||||
handleNativeChatQuestionAnswer,
|
||||
handleNativeChatSend,
|
||||
handleNativeChatSendWithOutcome
|
||||
handleNativeChatSendWithOutcome,
|
||||
readSeededLaunchDraft
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
sessionId = `session-${tabId}`,
|
||||
messages = [],
|
||||
launchDraft = null,
|
||||
launchDraftCreatedAt = null,
|
||||
chatActive = true,
|
||||
transcriptLoading = false
|
||||
}: {
|
||||
@@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
sessionId?: string | null
|
||||
messages?: NativeChatMessage[]
|
||||
launchDraft?: string | null
|
||||
launchDraftCreatedAt?: number | null
|
||||
chatActive?: boolean
|
||||
transcriptLoading?: boolean
|
||||
}): null {
|
||||
@@ -55,6 +57,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
sessionId,
|
||||
messages,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive,
|
||||
transcriptLoading
|
||||
})
|
||||
@@ -97,6 +100,21 @@ describe('useMobileNativeChatDrafts launch draft', () => {
|
||||
expect(state?.composerText).toBe('')
|
||||
})
|
||||
|
||||
it('captures the generation paired with the adopted text', async () => {
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
launchDraft: 'issue link',
|
||||
launchDraftCreatedAt: 7
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(state?.readSeededLaunchDraftSeed()).toEqual({ text: 'issue link', createdAt: 7 })
|
||||
})
|
||||
|
||||
it('does not overwrite typed composer text with a launch draft', async () => {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('typed first'))
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type UnconfirmedSend
|
||||
} from './mobile-native-chat-draft-reconcile'
|
||||
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
|
||||
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
|
||||
export type MobileNativeChatPendingMessage = {
|
||||
id: string
|
||||
@@ -43,6 +45,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string | null
|
||||
launchDraftCreatedAt?: number | null
|
||||
/** Whether the tab is currently resolved to the chat view. Off-chat the
|
||||
* launch-draft effects hold their state instead of acting on it. */
|
||||
chatActive?: boolean
|
||||
@@ -55,6 +58,11 @@ export function useMobileNativeChatDrafts(args: {
|
||||
setComposerText: Dispatch<SetStateAction<string>>
|
||||
pending: MobileNativeChatPendingMessage[]
|
||||
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
|
||||
/** Launch-context text still believed to be parked on the agent's TUI input
|
||||
* line, or null once it has been declined or retired. Send paths size their
|
||||
* pre-clear from it, since one Ctrl+U clears only one logical line. */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
|
||||
/** Clear the composer at send time, before the RPC settles. */
|
||||
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
|
||||
/** Put the text back after a definite rejection, unless newer edits exist. */
|
||||
@@ -73,6 +81,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
sessionId,
|
||||
messages,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive = true,
|
||||
transcriptLoading
|
||||
} = args
|
||||
@@ -91,62 +100,15 @@ export function useMobileNativeChatDrafts(args: {
|
||||
activePendingKeyRef.current = pendingKey
|
||||
const mountedRef = useRef(false)
|
||||
|
||||
// Seeded launch-context text per tab; '' marks a permanent decline so a
|
||||
// cleared composer never resurrects the prefill.
|
||||
const seededLaunchDraftByKeyRef = useRef(new Map<string, string>())
|
||||
|
||||
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
|
||||
// adopt it once as the composer draft so mobile shows the same context.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!draftKey ||
|
||||
!chatActive ||
|
||||
!launchDraft?.trim() ||
|
||||
seededLaunchDraftByKeyRef.current.has(draftKey)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: `session.tabs` carries launchDraft before the transcript read settles,
|
||||
// and an empty (or previous tab's) list would let the decline below misjudge
|
||||
// an already-submitted prefill — long enough for a send to duplicate it.
|
||||
if (transcriptLoading) {
|
||||
return
|
||||
}
|
||||
// A user turn already in the transcript means the one-line TUI prefill was
|
||||
// submitted or deliberately cleared; decline instead of resurrecting it.
|
||||
if (messages.some((message) => normalizedUserText(message) !== null)) {
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, '')
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft)
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
|
||||
|
||||
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
|
||||
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
|
||||
// it (desktop sent or reconciled it). User edits are always kept.
|
||||
useEffect(() => {
|
||||
// Same gates as the seed: off-chat there is no retraction to read (the tab
|
||||
// publishes no draft to us), and an untrusted transcript would wipe an
|
||||
// untouched copy on the strength of another tab's user turns.
|
||||
if (!draftKey || !chatActive || transcriptLoading) {
|
||||
return
|
||||
}
|
||||
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
|
||||
if (!seeded) {
|
||||
return
|
||||
}
|
||||
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
|
||||
if (!hasUserTurn && launchDraft?.trim()) {
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, '')
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
|
||||
const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({
|
||||
draftKey,
|
||||
messages,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive,
|
||||
transcriptLoading,
|
||||
setDrafts
|
||||
})
|
||||
|
||||
const setComposerText: Dispatch<SetStateAction<string>> = useCallback(
|
||||
(value) => {
|
||||
@@ -358,6 +320,8 @@ export function useMobileNativeChatDrafts(args: {
|
||||
setComposerText,
|
||||
pending,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
@@ -69,6 +70,7 @@ function baseArgs(overrides: Partial<HookArgs> & Pick<HookArgs, 'client'>): Hook
|
||||
showToast: vi.fn(),
|
||||
onSendError: vi.fn(),
|
||||
baseSend: vi.fn().mockResolvedValue('accepted'),
|
||||
readSeededLaunchDraft: () => null,
|
||||
sleep: async () => {},
|
||||
...overrides
|
||||
}
|
||||
@@ -201,6 +203,40 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
expect(hook!.attachments).toEqual([])
|
||||
})
|
||||
|
||||
it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => {
|
||||
// A single Ctrl+U kills only the last line, so the draft's earlier lines
|
||||
// would survive the clear and ride along with the image as prompt body.
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
sendResult(true),
|
||||
sendResult(true)
|
||||
])
|
||||
const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
mount(
|
||||
baseArgs({
|
||||
client: client as unknown as RpcClient,
|
||||
deviceTokenRef: { current: 'device-1' },
|
||||
readSeededLaunchDraft: () => draft
|
||||
})
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('look at this')
|
||||
})
|
||||
|
||||
const firstSend = client.calls.find((c) => c.method === 'terminal.send')
|
||||
expect(firstSend?.params).toMatchObject({
|
||||
text: buildAgentTuiClearInputForText(draft),
|
||||
enter: false
|
||||
})
|
||||
expect(firstSend?.params.text).not.toBe('\x15')
|
||||
})
|
||||
|
||||
it('spends one budget across the image paste and the text body that follows', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
@@ -57,6 +58,10 @@ type Args = {
|
||||
imagePreviewUris?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text parked on the agent's TUI input line, or null. The
|
||||
* paste's leading clear must cover every line of it, or the draft's earlier
|
||||
* lines survive and ride along with the image. */
|
||||
readonly readSeededLaunchDraft: () => string | null
|
||||
readonly onAttachSuccess?: () => void
|
||||
readonly onError?: () => void
|
||||
// Injected so the settle between image paste and submit is instant in tests.
|
||||
@@ -107,6 +112,7 @@ export function useMobileNativeChatImageAttachments({
|
||||
showToast,
|
||||
onSendError,
|
||||
baseSend,
|
||||
readSeededLaunchDraft,
|
||||
onAttachSuccess,
|
||||
onError,
|
||||
sleep = defaultSleep
|
||||
@@ -270,12 +276,16 @@ export function useMobileNativeChatImageAttachments({
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const seededLaunchDraft = readSeededLaunchDraft()
|
||||
const pasted = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: handle,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
imagePaths: pendingImages.map((attachment) => attachment.path),
|
||||
deadline
|
||||
deadline,
|
||||
...(seededLaunchDraft
|
||||
? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) }
|
||||
: {})
|
||||
})
|
||||
if (!pasted) {
|
||||
// Keep the chips so the user can retry; the failed paste never submitted.
|
||||
@@ -352,6 +362,7 @@ export function useMobileNativeChatImageAttachments({
|
||||
enabled,
|
||||
onError,
|
||||
onSendError,
|
||||
readSeededLaunchDraft,
|
||||
scopeKey,
|
||||
sleep
|
||||
]
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { normalizedUserText } from './mobile-native-chat-draft-reconcile'
|
||||
|
||||
export type MobileNativeChatLaunchDraftSeed = {
|
||||
text: string
|
||||
createdAt: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopting the host's launch-context prefill as the mobile composer draft, and
|
||||
* retiring it again once it is resolved elsewhere. Split out of the drafts hook
|
||||
* so the general draft/pending accounting stays separate from this one concern.
|
||||
*/
|
||||
export function useMobileNativeChatLaunchDraftSeed(args: {
|
||||
draftKey: string | null
|
||||
messages: readonly NativeChatMessage[]
|
||||
/** Host-provided launch context still parked as an unsent TUI-input draft. */
|
||||
launchDraft?: string | null
|
||||
launchDraftCreatedAt?: number | null
|
||||
chatActive: boolean
|
||||
transcriptLoading?: boolean
|
||||
setDrafts: Dispatch<SetStateAction<Record<string, string>>>
|
||||
}): {
|
||||
/** Text still believed to be parked on the agent's TUI input line, or null
|
||||
* once declined or retired. Send paths size their pre-clear from it, since
|
||||
* one Ctrl+U clears only one logical line. */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
|
||||
} {
|
||||
const {
|
||||
draftKey,
|
||||
messages,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
chatActive,
|
||||
transcriptLoading,
|
||||
setDrafts
|
||||
} = args
|
||||
|
||||
// Seeded launch-context text per tab; null marks a permanent decline so a
|
||||
// cleared composer never resurrects the prefill.
|
||||
const seededLaunchDraftByKeyRef = useRef(
|
||||
new Map<string, MobileNativeChatLaunchDraftSeed | null>()
|
||||
)
|
||||
|
||||
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
|
||||
// adopt it once as the composer draft so mobile shows the same context.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!draftKey ||
|
||||
!chatActive ||
|
||||
!launchDraft?.trim() ||
|
||||
seededLaunchDraftByKeyRef.current.has(draftKey)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: `session.tabs` carries launchDraft before the transcript read settles,
|
||||
// and an empty (or previous tab's) list would let the decline below misjudge
|
||||
// an already-submitted prefill — long enough for a send to duplicate it.
|
||||
if (transcriptLoading) {
|
||||
return
|
||||
}
|
||||
// A user turn already in the transcript means the TUI prefill was submitted
|
||||
// or deliberately cleared; decline instead of resurrecting it.
|
||||
if (messages.some((message) => normalizedUserText(message) !== null)) {
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, null)
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, {
|
||||
text: launchDraft,
|
||||
createdAt: launchDraftCreatedAt ?? null
|
||||
})
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
|
||||
)
|
||||
}, [
|
||||
chatActive,
|
||||
draftKey,
|
||||
launchDraft,
|
||||
launchDraftCreatedAt,
|
||||
messages,
|
||||
setDrafts,
|
||||
transcriptLoading
|
||||
])
|
||||
|
||||
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
|
||||
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
|
||||
// it (desktop sent or reconciled it). User edits are always kept.
|
||||
useEffect(() => {
|
||||
// Same gates as the seed: off-chat there is no retraction to read (the tab
|
||||
// publishes no draft to us), and an untrusted transcript would wipe an
|
||||
// untouched copy on the strength of another tab's user turns.
|
||||
if (!draftKey || !chatActive || transcriptLoading) {
|
||||
return
|
||||
}
|
||||
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
|
||||
if (!seeded) {
|
||||
return
|
||||
}
|
||||
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
|
||||
if (!hasUserTurn && launchDraft?.trim()) {
|
||||
return
|
||||
}
|
||||
seededLaunchDraftByKeyRef.current.set(draftKey, null)
|
||||
setDrafts((previous) =>
|
||||
(previous[draftKey] ?? '') === seeded.text ? { ...previous, [draftKey]: '' } : previous
|
||||
)
|
||||
}, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading])
|
||||
|
||||
// A missing or declined entry means there is nothing of ours on the TUI line.
|
||||
const readSeededLaunchDraft = useCallback(
|
||||
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey)?.text ?? null) : null),
|
||||
[draftKey]
|
||||
)
|
||||
const readSeededLaunchDraftSeed = useCallback(
|
||||
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey) ?? null) : null),
|
||||
[draftKey]
|
||||
)
|
||||
|
||||
return { readSeededLaunchDraft, readSeededLaunchDraftSeed }
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Covers the wiring the image-attachments suite structurally cannot: that hook
|
||||
// injects its own baseSend stub, so it never observes the real send params.
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const sendWithOutcome = vi.fn()
|
||||
const clearInputWrite = vi.fn()
|
||||
vi.mock('./mobile-native-chat-send', () => ({
|
||||
sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args),
|
||||
clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args),
|
||||
openMobileNativeChatSendBudget: () => Date.now() + 15_000,
|
||||
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000,
|
||||
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS: 2_000
|
||||
}))
|
||||
vi.mock('./mobile-native-chat-stale-input', () => ({
|
||||
healMobileNativeChatStaleInput: () => Promise.resolve(true)
|
||||
}))
|
||||
|
||||
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
type Send = ReturnType<typeof useMobileNativeChatMessageSend>
|
||||
|
||||
const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
|
||||
describe('useMobileNativeChatMessageSend', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let api: Send | null = null
|
||||
|
||||
const mount = (
|
||||
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null
|
||||
): void => {
|
||||
function Probe(): null {
|
||||
api = useMobileNativeChatMessageSend({
|
||||
client: { sendRequest: vi.fn() } as never,
|
||||
enabled: true,
|
||||
handleRef: { current: 'term' },
|
||||
deviceTokenRef: { current: 'device' },
|
||||
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend: () => {},
|
||||
restoreRejectedDraft: () => {},
|
||||
acceptSend: () => {},
|
||||
holdUnconfirmedSend: () => {},
|
||||
onSendError: () => {}
|
||||
})
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe))
|
||||
})
|
||||
}
|
||||
|
||||
const sentArgs = (): {
|
||||
clearInputFirst?: boolean
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
} =>
|
||||
sendWithOutcome.mock.calls[0]![0] as {
|
||||
clearInputFirst?: boolean
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
}
|
||||
const clearArgs = (): { clearInput?: string } =>
|
||||
(clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string }
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
sendWithOutcome.mockReset()
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
clearInputWrite.mockReset()
|
||||
clearInputWrite.mockResolvedValue(true)
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
renderer?.unmount()
|
||||
})
|
||||
renderer = null
|
||||
api = null
|
||||
})
|
||||
|
||||
it('sizes the pre-clear to every line of a parked launch draft', async () => {
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(clearArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT))
|
||||
})
|
||||
|
||||
it('issues the burst as its OWN write, before the body', async () => {
|
||||
// Bundled into the body write it arrived as literal Ctrl+U text.
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(clearInputWrite).toHaveBeenCalledTimes(1)
|
||||
expect(sendWithOutcome).toHaveBeenCalledTimes(1)
|
||||
expect(clearInputWrite.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
sendWithOutcome.mock.invocationCallOrder[0]!
|
||||
)
|
||||
})
|
||||
|
||||
it('aborts without sending the body when the clear is rejected', async () => {
|
||||
// Sending on top of an uncleared line is exactly the concatenation bug.
|
||||
clearInputWrite.mockResolvedValue(false)
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
let result: boolean | undefined
|
||||
await act(async () => {
|
||||
result = await api!.send('hello')
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => {
|
||||
// A Ctrl+U written immediately before body text in the SAME write arrives as
|
||||
// a literal control character, so it would head the received message.
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(sentArgs().clearInputFirst).toBe(false)
|
||||
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
|
||||
})
|
||||
|
||||
it('keeps the single-Ctrl+U prefix when no dedicated clear ran', async () => {
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(sentArgs().clearInputFirst).toBe(true)
|
||||
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes no clear at all when nothing is parked on the line', async () => {
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(clearInputWrite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the draft at send time, so a retired seed stops widening the clear', async () => {
|
||||
let parked: { text: string; createdAt: number } | null = { text: DRAFT, createdAt: 1 }
|
||||
mount(() => parked)
|
||||
await act(async () => {
|
||||
await api!.send('first')
|
||||
})
|
||||
parked = null
|
||||
await act(async () => {
|
||||
await api!.send('second')
|
||||
})
|
||||
expect(sendWithOutcome.mock.calls[1]![0]).toMatchObject({ clearInputFirst: true })
|
||||
expect(clearInputWrite).toHaveBeenCalledTimes(1)
|
||||
expect(sendWithOutcome.mock.calls[0]![0]).toMatchObject({ clearInputFirst: false })
|
||||
})
|
||||
|
||||
it('does not clear an image send after the image was pasted', async () => {
|
||||
// A second clear here would wipe the image that was just pasted.
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.send('caption', ['file:///a.png'])
|
||||
})
|
||||
expect(clearInputWrite).not.toHaveBeenCalled()
|
||||
expect(sentArgs().clearInputFirst).toBe(false)
|
||||
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
|
||||
})
|
||||
|
||||
it('does not resolve a composer seed from a question-card answer', async () => {
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
await act(async () => {
|
||||
await api!.answerQuestion('1')
|
||||
})
|
||||
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, type MutableRefObject } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
clearMobileNativeChatInput,
|
||||
openMobileNativeChatSendBudget,
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
type MobileNativeChatSendOutcome
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
|
||||
import { t } from '@/i18n/mobile-i18n'
|
||||
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
export type MobileNativeChatMessageSend = {
|
||||
/** Composer send that syncs the draft (clear on send, restore on rejection). */
|
||||
@@ -33,6 +36,9 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
handleRef: MutableRefObject<string | null>
|
||||
deviceTokenRef: MutableRefObject<string | null>
|
||||
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
|
||||
/** Launch-context text Orca parked on the agent's TUI input line, or null. Read
|
||||
* at send time so the pre-clear can be sized to every line it occupies. */
|
||||
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
|
||||
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
|
||||
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
|
||||
acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
|
||||
@@ -49,6 +55,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
handleRef,
|
||||
deviceTokenRef,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
restoreRejectedDraft,
|
||||
acceptSend,
|
||||
@@ -96,6 +103,36 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
if (syncComposer) {
|
||||
clearDraftForSend(origin, text)
|
||||
}
|
||||
// Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears
|
||||
// only one logical line. Size the clear to the text Orca injected, with
|
||||
// slack — the user can also have typed into the TUI line directly, so that
|
||||
// line count is a lower bound. Mobile cannot read the agent's screen, so
|
||||
// there is no empty-line observable to confirm against here; the upper
|
||||
// bound plus the host's write acceptance is what makes it safe.
|
||||
//
|
||||
// The burst goes out as its OWN write: bundled into the body write it
|
||||
// arrived as literal Ctrl+U text and the draft concatenated (see
|
||||
// clearMobileNativeChatInput). A rejected clear aborts the send rather
|
||||
// than pasting on top of an uncleared line.
|
||||
const seededLaunchDraft = readSeededLaunchDraftSeed()
|
||||
if (seededLaunchDraft && !images?.length) {
|
||||
const cleared = await clearMobileNativeChatInput({
|
||||
client,
|
||||
terminal: handle,
|
||||
clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text),
|
||||
deadline,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
: {})
|
||||
})
|
||||
if (!cleared) {
|
||||
if (syncComposer) {
|
||||
restoreRejectedDraft(origin, text)
|
||||
}
|
||||
onSendError(t('m.ZkABdzg'))
|
||||
return 'rejected'
|
||||
}
|
||||
}
|
||||
const outcome = await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
@@ -106,7 +143,20 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
// this message. An image send already led its own paste with Ctrl+U, and a
|
||||
// second one here would wipe the image it just pasted (desktop's image path
|
||||
// likewise clears once, before the paste, and never again).
|
||||
clearInputFirst: !images?.length,
|
||||
//
|
||||
// Also skipped once the dedicated clear above ran: the line is already
|
||||
// empty, and a Ctrl+U written immediately before body text in the SAME
|
||||
// write reaches the agent as a literal control character rather than a
|
||||
// keypress (observed live as a stray \x15 heading the received message).
|
||||
clearInputFirst: !images?.length && !seededLaunchDraft,
|
||||
...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
|
||||
? {
|
||||
resolvedLaunchDraft: {
|
||||
text: seededLaunchDraft.text,
|
||||
createdAt: seededLaunchDraft.createdAt
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
deadline,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
@@ -140,6 +190,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
handleRef,
|
||||
holdUnconfirmedSend,
|
||||
onSendError,
|
||||
readSeededLaunchDraftSeed,
|
||||
restoreRejectedDraft
|
||||
]
|
||||
)
|
||||
|
||||
@@ -31,6 +31,9 @@ type Args = {
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text parked on the agent's TUI input line, or null — sizes
|
||||
* the image paste's leading clear so a multi-line draft cannot ride along. */
|
||||
readonly readSeededLaunchDraft: () => string | null
|
||||
readonly showToast: (message: string, durationMs?: number) => void
|
||||
/** Native-chat send failures — rendered in the composer's inline banner. */
|
||||
readonly onNativeChatSendError: (message: string) => void
|
||||
@@ -54,6 +57,7 @@ export function useMobileSessionImageAttachments({
|
||||
getActiveWorktreeConnectionId,
|
||||
beforeTerminalSend,
|
||||
nativeChatBaseSend,
|
||||
readSeededLaunchDraft,
|
||||
showToast,
|
||||
onNativeChatSendError,
|
||||
onSuccess,
|
||||
@@ -86,6 +90,7 @@ export function useMobileSessionImageAttachments({
|
||||
showToast,
|
||||
onSendError: onNativeChatSendError,
|
||||
baseSend: nativeChatBaseSend,
|
||||
readSeededLaunchDraft,
|
||||
onAttachSuccess: onSuccess,
|
||||
onError
|
||||
})
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ export const BOOLEAN_FLAGS = new Set([
|
||||
'include-archived',
|
||||
'interrupt',
|
||||
'json',
|
||||
'local',
|
||||
'messages',
|
||||
'me',
|
||||
'mobile',
|
||||
@@ -55,7 +56,7 @@ export const BOOLEAN_FLAGS = new Set([
|
||||
])
|
||||
|
||||
export const REPEATED_FLAG_SEPARATOR = '\u0000'
|
||||
const REPEATABLE_STRING_FLAGS = new Set(['label'])
|
||||
const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill'])
|
||||
|
||||
function setFlagValue(flags: Map<string, string | boolean>, name: string, value: string): void {
|
||||
const existing = flags.get(name)
|
||||
|
||||
@@ -218,7 +218,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
keys: ['skills list', 'skills get'],
|
||||
keys: ['skills list', 'skills get', 'skills install', 'skills update'],
|
||||
load: async () => (await import('./handlers/skills.js')).SKILL_HANDLERS
|
||||
}
|
||||
]
|
||||
|
||||
@@ -63,6 +63,41 @@ describe('lightweight Run CLI handlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes Run pagination flags to the runtime', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { runs: [], nextCursor: null }
|
||||
})
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-list']({
|
||||
flags: new Map([
|
||||
['limit', '25'],
|
||||
['cursor', 'next-page']
|
||||
]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.runList', {
|
||||
limit: 25,
|
||||
cursor: 'next-page'
|
||||
})
|
||||
})
|
||||
|
||||
it('opts into bounded Run pagination by default', async () => {
|
||||
callMock.mockResolvedValue({ result: { runs: [], nextCursor: null } })
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-list']({
|
||||
flags: new Map(),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.runList', {
|
||||
limit: 100,
|
||||
cursor: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('passes explicit legacy takeover only when requested', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { run: { id: 'run_adopted', objective: 'Recovered work' } }
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract'
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination'
|
||||
import {
|
||||
formatMessageReadOnlyTag,
|
||||
formatOrchestrationCheckText,
|
||||
@@ -463,19 +464,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration run-list': async ({ client, json }) => {
|
||||
'orchestration run-list': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{
|
||||
runs: { id: string; objective: string; legacy: number }[]
|
||||
}>('orchestration.runList', {})
|
||||
printResult(result, json, (r) =>
|
||||
r.runs.length === 0
|
||||
? 'No Runs found.'
|
||||
: r.runs
|
||||
.map(
|
||||
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
|
||||
)
|
||||
.join('\n')
|
||||
)
|
||||
nextCursor: string | null
|
||||
}>('orchestration.runList', {
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT,
|
||||
cursor: getOptionalStringFlag(flags, 'cursor')
|
||||
})
|
||||
printResult(result, json, (r) => {
|
||||
const rows =
|
||||
r.runs.length === 0
|
||||
? 'No Runs found.'
|
||||
: r.runs
|
||||
.map(
|
||||
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
|
||||
)
|
||||
.join('\n')
|
||||
return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration run-show': async ({ flags, client, json }) => {
|
||||
|
||||
+272
-1
@@ -1,5 +1,21 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { delimiter, dirname } from 'node:path'
|
||||
import { getRepeatedStringFlag } from '../flags'
|
||||
import { resolveCliCommand } from '../../main/codex-cli/command'
|
||||
import { detectCommandsInInstallDirs } from '../../main/ipc/local-agent-install-dir-detection'
|
||||
import {
|
||||
getTuiAgentDetectionProbeCommands,
|
||||
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
|
||||
resolveDetectedTuiAgentIds
|
||||
} from '../../main/ipc/tui-agent-detection-commands'
|
||||
import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError } from '../../main/win32-utils'
|
||||
import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys'
|
||||
import {
|
||||
buildAgentFeatureSkillInstallArgs,
|
||||
buildAgentFeatureSkillUpdateArgs
|
||||
} from '../../shared/agent-feature-install-commands'
|
||||
|
||||
type BundledSkillGuide = {
|
||||
name: string
|
||||
@@ -46,6 +62,259 @@ function writeStdout(value: string): void {
|
||||
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
|
||||
}
|
||||
|
||||
function resolveSelectedSkillNames(
|
||||
flags: Map<string, string | boolean>,
|
||||
guides: BundledSkillGuide[]
|
||||
): string[] {
|
||||
const requestedSkills = getRepeatedStringFlag(flags, 'skill')
|
||||
const selectAll = flags.get('all') === true
|
||||
if (flags.has('skill') && requestedSkills.length === 0) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --skill')
|
||||
}
|
||||
if (selectAll && requestedSkills.length > 0) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --all or --skill, not both.')
|
||||
}
|
||||
if (!selectAll && requestedSkills.length === 0) {
|
||||
return []
|
||||
}
|
||||
if (selectAll) {
|
||||
return guides.map((guide) => guide.name)
|
||||
}
|
||||
const availableTopics = guides.map((guide) => guide.name).join(', ')
|
||||
const guideByTopic = new Map<string, BundledSkillGuide>(
|
||||
guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide]))
|
||||
)
|
||||
const canonicalNames = new Set<string>()
|
||||
for (const requested of requestedSkills) {
|
||||
const guide = guideByTopic.get(requested)
|
||||
if (!guide) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown skill "${requested}". Available skills: ${availableTopics}`
|
||||
)
|
||||
}
|
||||
canonicalNames.add(guide.name)
|
||||
}
|
||||
return [...canonicalNames].sort()
|
||||
}
|
||||
|
||||
/** PATH with the resolved npx's own directory first, so its `env node` shebang resolves. */
|
||||
function buildNpxPath(resolvedNpx: string): string {
|
||||
// Why: resolveCliCommand falls back to the bare name when it finds nothing, and
|
||||
// dirname('npx') is '.', so prepending it blindly would run ./npx out of the
|
||||
// caller's checkout instead of reporting that npx is missing.
|
||||
const own = dirname(resolvedNpx)
|
||||
const existing = process.env.PATH ?? process.env.Path ?? ''
|
||||
// Why: every version manager ships node beside npx in the same bin directory,
|
||||
// so the resolved sibling is all the child needs to run npx's shebang.
|
||||
return [own === '.' ? '' : own, existing].filter(Boolean).join(delimiter)
|
||||
}
|
||||
|
||||
function runNpxSkills(args: string[]): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Why: a bare PATH lookup misses nvm/fnm/volta installs, and hardcoding
|
||||
// `npx.cmd` both misses `npx.exe` shims and hides a missing npx behind
|
||||
// cmd.exe's own exit code, so the friendly error below never fires.
|
||||
const resolved = resolveCliCommand('npx')
|
||||
let spawnCmd: string
|
||||
let spawnArgs: string[]
|
||||
try {
|
||||
;({ spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolved, args))
|
||||
} catch (error) {
|
||||
// Why: the guard rejects cmd metacharacters, and only the resolved npx
|
||||
// path can carry them here — a username like `A&B` puts them in it.
|
||||
if (!(error instanceof UnsafeWindowsBatchArgumentsError)) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'invalid_environment',
|
||||
`Cannot run npx from "${resolved}": the path contains characters cmd.exe would ` +
|
||||
'reinterpret. Install Node.js somewhere without & | < > ^ " % ! in the path.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: npx is an `#!/usr/bin/env node` script, so resolving it off PATH is
|
||||
// not enough — without node alongside it the child exits 127 with no
|
||||
// 'error' event and the message below never fires.
|
||||
const child = spawn(spawnCmd, spawnArgs, {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, PATH: buildNpxPath(resolved) }
|
||||
})
|
||||
// Why: a missing npx/Node on a headless host surfaces as a raw spawn ENOENT;
|
||||
// wrap it so the CLI reports an actionable message like every other failure here.
|
||||
child.once('error', (error) => {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
reject(
|
||||
new RuntimeClientError(
|
||||
'invalid_environment',
|
||||
`Could not run npx: ${detail}. Install Node.js and ensure npx is on PATH.`
|
||||
)
|
||||
)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
resolve(typeof code === 'number' ? code : signal ? 1 : 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type SkillMutationVerb = 'install' | 'update'
|
||||
|
||||
/** Agents Orca can see on this host, as `skills --agent` keys. */
|
||||
function detectSkillsCliAgentKeys(): string[] {
|
||||
const runtime = process.platform
|
||||
const probes = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime)
|
||||
const detected = resolveDetectedTuiAgentIds(
|
||||
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
|
||||
detectCommandsInInstallDirs(probes),
|
||||
runtime
|
||||
)
|
||||
return detected.length === 0 ? [] : toSkillsCliAgentKeys(detected)
|
||||
}
|
||||
|
||||
function resolveInstallAgentKeys(flags: Map<string, string | boolean>): string[] {
|
||||
const requested = flags.get('agent')
|
||||
if (flags.has('agent') && typeof requested !== 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --agent')
|
||||
}
|
||||
if (typeof requested === 'string') {
|
||||
// Why: one comma-separated value rather than a repeatable flag — `agent` is a
|
||||
// single-value flag on other commands and the repeatable set is process-wide,
|
||||
// so making it repeatable here would change how those parse a second --agent.
|
||||
const keys = [
|
||||
...new Set(
|
||||
requested
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
]
|
||||
// Why: a value like "," parses to nothing. Falling through to detection would
|
||||
// be surprising, and emitting no --agent would restore the all-agents install.
|
||||
if (keys.length === 0) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --agent')
|
||||
}
|
||||
const unusable = keys.find((key) => !isSkillsCliAgentKeyShaped(key))
|
||||
if (unusable !== undefined) {
|
||||
// Why: the skills CLI drops a value starting with `-`, which leaves it with
|
||||
// no target and installs into every agent it knows.
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Invalid --agent value "${unusable}". Pass agent names such as claude-code, ` +
|
||||
'codex, or universal.'
|
||||
)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
const detected = detectSkillsCliAgentKeys()
|
||||
if (detected.length > 0) {
|
||||
return detected
|
||||
}
|
||||
// Why: without --agent, `skills add -y` falls into its own zero-detected branch
|
||||
// and installs into every agent it knows (~75), creating config directories for
|
||||
// agents this host does not have. Say so instead.
|
||||
throw new RuntimeClientError(
|
||||
'invalid_environment',
|
||||
'No coding agent detected on this host, so there is no install target. Pass ' +
|
||||
'--agent <name>[,<name>...] to choose targets explicitly — --agent universal ' +
|
||||
'writes only the shared .agents/skills directory that Orca reads.'
|
||||
)
|
||||
}
|
||||
|
||||
function buildNpxSkillsArgs(
|
||||
verb: SkillMutationVerb,
|
||||
skillNames: string[],
|
||||
global: boolean,
|
||||
agents: string[]
|
||||
): string[] {
|
||||
const skillArgs =
|
||||
verb === 'install'
|
||||
? buildAgentFeatureSkillInstallArgs(skillNames, { global, yes: true, agents })
|
||||
: buildAgentFeatureSkillUpdateArgs(skillNames, { global, yes: true })
|
||||
// Why: a cold package cache makes bare `npx` prompt before it will fetch
|
||||
// `skills`, which strands an unattended host just like the picker does.
|
||||
return ['--yes', ...skillArgs]
|
||||
}
|
||||
|
||||
/** Render the exact argv a real run spawns, so --dry-run can never drift from it. */
|
||||
function formatNpxCommand(args: string[]): string {
|
||||
return `npx ${args.join(' ')}`
|
||||
}
|
||||
|
||||
function formatSkillSelectionHelp(verb: SkillMutationVerb, skillNames: string[]): string {
|
||||
return [
|
||||
`Choose one or more skills to ${verb}:`,
|
||||
...skillNames.map((name) => ` ${name}`),
|
||||
'',
|
||||
`Usage: orca skills ${verb} --skill <name> [--skill <name> ...]`,
|
||||
` or: orca skills ${verb} --all`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler {
|
||||
return async ({ flags, json }) => {
|
||||
// Why: keep the large generated table off the eager handler registry path.
|
||||
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
|
||||
const guides = canonicalGuides(BUNDLED_SKILL_GUIDES)
|
||||
const skillNames = resolveSelectedSkillNames(flags, guides)
|
||||
|
||||
if (skillNames.length === 0) {
|
||||
const names = guides.map((guide) => guide.name)
|
||||
writeStdout(
|
||||
json
|
||||
? JSON.stringify({ availableSkills: names }, null, 2)
|
||||
: formatSkillSelectionHelp(verb, names)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Why: this runs before target resolution because the answer belongs to the
|
||||
// other machine — agents detected here would be the wrong host's, and a host
|
||||
// that detects none would hide the forwarding problem behind that error.
|
||||
if (process.env.ORCA_CLI_CWD) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_environment',
|
||||
`orca skills ${verb} writes to the machine that runs it, but this shell forwards ` +
|
||||
`orca to the Orca host. Run the same orca skills ${verb} command on the machine ` +
|
||||
"you want it on, where it can detect that host's agents."
|
||||
)
|
||||
}
|
||||
|
||||
const global = flags.get('local') !== true
|
||||
// Why: install scopes its targets; update only refreshes what is already placed.
|
||||
const agents = verb === 'install' ? resolveInstallAgentKeys(flags) : []
|
||||
const npxArgs = buildNpxSkillsArgs(verb, skillNames, global, agents)
|
||||
const command = formatNpxCommand(npxArgs)
|
||||
const dryRun = flags.get('dry-run') === true
|
||||
|
||||
if (dryRun) {
|
||||
writeStdout(
|
||||
json
|
||||
? JSON.stringify({ command, skills: skillNames, global, executed: false }, null, 2)
|
||||
: `${command}\n\nRerun without --dry-run to ${verb} now.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (json) {
|
||||
// Why: a real run inherits npx's own stdout so progress stays visible live;
|
||||
// that stream is not JSON, so --json can't be honored here.
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`orca skills ${verb} --json only supports --dry-run. Real ${verb}s stream ` +
|
||||
"npx's own output, which isn't JSON."
|
||||
)
|
||||
}
|
||||
|
||||
// Why: stdio is inherited for the child below, so this status line must go to
|
||||
// stderr — stdout is npx's own output, not this command's JSON channel.
|
||||
process.stderr.write(`Running: ${command}\n`)
|
||||
process.exitCode = await runNpxSkills(npxArgs)
|
||||
}
|
||||
}
|
||||
|
||||
export const SKILL_HANDLERS: Record<string, CommandHandler> = {
|
||||
'skills list': async ({ json }) => {
|
||||
// Why: the embedded guide table is large, so unrelated CLI commands must not
|
||||
@@ -72,5 +341,7 @@ export const SKILL_HANDLERS: Record<string, CommandHandler> = {
|
||||
const full = flags.has('full')
|
||||
const markdown = full ? guide.fullMarkdown : guide.markdown
|
||||
writeStdout(json ? JSON.stringify({ name: guide.name, full, markdown }, null, 2) : markdown)
|
||||
}
|
||||
},
|
||||
'skills install': createSkillMutationHandler('install'),
|
||||
'skills update': createSkillMutationHandler('update')
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ Agent Discovery:
|
||||
Skills:
|
||||
skills list List version-matched skill guides bundled with this Orca CLI
|
||||
skills get Print a version-matched skill guide as Markdown
|
||||
skills install Install bundled Orca skills globally via the community skills CLI
|
||||
skills update Update already-installed Orca skills via the community skills CLI
|
||||
|
||||
Environments:
|
||||
environment add Save a remote Orca runtime from a pairing code
|
||||
@@ -412,6 +414,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string {
|
||||
|
||||
function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
const command = commandPath.join(' ')
|
||||
if (command === 'skills install' && flag === 'agent') {
|
||||
return '--agent <names> Comma-separated install targets; default is detected agents'
|
||||
}
|
||||
if (command === 'terminal close' && flag === 'tab') {
|
||||
return '--tab Close the whole tab and wait for durable persistence'
|
||||
}
|
||||
@@ -521,6 +526,8 @@ export function formatFlagHelp(flag: string): string {
|
||||
json: '--json Emit machine-readable JSON',
|
||||
key: '--key <key> Key argument for this command',
|
||||
limit: '--limit <n> Maximum number of rows to return',
|
||||
local: '--local Target the current project instead of the global install',
|
||||
skill: '--skill <name> Bundled skill to act on; repeat for several',
|
||||
mode: '--mode <mode> Mode such as edit, diff, or both',
|
||||
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
|
||||
modifiers: '--modifiers <chord> Modifier keys held only for this click',
|
||||
|
||||
+778
-26
@@ -1,8 +1,36 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { delimiter } from 'node:path'
|
||||
import type * as CodexCliCommandModule from '../main/codex-cli/command'
|
||||
|
||||
const { guideModuleLoadMock, runtimeClientConstructorMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
detectCommandsMock,
|
||||
guideModuleLoadMock,
|
||||
resolveCliCommandMock,
|
||||
runtimeClientConstructorMock,
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
detectCommandsMock: vi.fn(() => new Set<string>(['claude'])),
|
||||
guideModuleLoadMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn()
|
||||
resolveCliCommandMock: vi.fn(() => 'npx'),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
// Why: agent detection probes the real machine, so pin it or every install
|
||||
// assertion depends on what the test runner happens to have installed.
|
||||
vi.mock('../main/ipc/local-agent-install-dir-detection', () => ({
|
||||
detectCommandsInInstallDirs: detectCommandsMock
|
||||
}))
|
||||
|
||||
// Why: override only the npx lookup so the real Windows .cmd rail still runs.
|
||||
vi.mock('../main/codex-cli/command', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexCliCommandModule>()),
|
||||
resolveCliCommand: resolveCliCommandMock
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('./bundled-skill-guides.js', () => {
|
||||
@@ -22,38 +50,33 @@ vi.mock('./bundled-skill-guides.js', () => {
|
||||
markdown: '# Alpha\n\nShort.\n',
|
||||
fullMarkdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n',
|
||||
aliases: ['legacy-alpha']
|
||||
},
|
||||
{
|
||||
name: 'gamma',
|
||||
description:
|
||||
'Use when gamma work spans several sentences describing exactly how a ' +
|
||||
'coding agent should decide whether gamma applies to the current task at hand.',
|
||||
markdown: '# Gamma\n',
|
||||
fullMarkdown: '# Gamma\n\n## References\n\nGamma reference.\n',
|
||||
aliases: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./runtime-client', () => {
|
||||
vi.mock('./runtime-client', async () => {
|
||||
// Why: re-export the REAL error classes rather than redefining them. format.ts
|
||||
// narrows with `instanceof` against ./runtime/types, so a look-alike class
|
||||
// here would make every CLI error fall through to the generic `runtime_error`
|
||||
// shape — mirroring the barrel keeps the mock faithful to production.
|
||||
const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js')
|
||||
|
||||
class RuntimeClient {
|
||||
constructor() {
|
||||
runtimeClientConstructorMock()
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
readonly data?: unknown
|
||||
|
||||
constructor(code: string, message: string, data?: unknown) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: unknown
|
||||
|
||||
constructor(response: unknown) {
|
||||
super('runtime_error', 'runtime_error')
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
@@ -70,9 +93,19 @@ describe('orca skills CLI', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
runtimeClientConstructorMock.mockClear()
|
||||
resolveCliCommandMock.mockReset()
|
||||
resolveCliCommandMock.mockReturnValue('npx')
|
||||
detectCommandsMock.mockReset()
|
||||
detectCommandsMock.mockReturnValue(new Set<string>(['claude']))
|
||||
spawnMock.mockReset()
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('keeps the bundled table off the eager command-registry path', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -102,7 +135,10 @@ describe('orca skills CLI', () => {
|
||||
await main(['skills', 'list'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
'alpha: Use when alpha work is needed.\nzeta: Use when zeta work spans lines.\n'
|
||||
'alpha: Use when alpha work is needed.\n' +
|
||||
'gamma: Use when gamma work spans several sentences describing exactly how a ' +
|
||||
'coding agent should decide whether gamma applies to the current task at hand.\n' +
|
||||
'zeta: Use when zeta work spans lines.\n'
|
||||
)
|
||||
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -133,6 +169,12 @@ describe('orca skills CLI', () => {
|
||||
{
|
||||
topics: [
|
||||
{ name: 'alpha', description: 'Use when alpha work is needed.' },
|
||||
{
|
||||
name: 'gamma',
|
||||
description:
|
||||
'Use when gamma work spans several sentences describing exactly how a ' +
|
||||
'coding agent should decide whether gamma applies to the current task at hand.'
|
||||
},
|
||||
{ name: 'zeta', description: 'Use when zeta work spans lines.' }
|
||||
]
|
||||
},
|
||||
@@ -176,7 +218,14 @@ describe('orca skills CLI', () => {
|
||||
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
|
||||
'get Print a version-matched skill guide'
|
||||
)
|
||||
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
|
||||
'install Install bundled Orca skills'
|
||||
)
|
||||
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
|
||||
'update Update already-installed Orca skills'
|
||||
)
|
||||
expect(String(logSpy.mock.calls[2]?.[0])).toContain('Skills:\n skills list')
|
||||
expect(String(logSpy.mock.calls[2]?.[0])).toContain('skills update')
|
||||
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -187,12 +236,715 @@ describe('orca skills CLI', () => {
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Unknown skill topic "missing". Available topics: alpha, zeta'
|
||||
'Unknown skill topic "missing". Available topics: alpha, gamma, zeta'
|
||||
)
|
||||
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists installable skills when no --skill/--all is given', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'install'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
[
|
||||
'Choose one or more skills to install:',
|
||||
' alpha',
|
||||
' gamma',
|
||||
' zeta',
|
||||
'',
|
||||
'Usage: orca skills install --skill <name> [--skill <name> ...]',
|
||||
' or: orca skills install --all',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives install --json (no selection) a stable schema', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'install', '--json'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
`${JSON.stringify({ availableSkills: ['alpha', 'gamma', 'zeta'] }, null, 2)}\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects combining --all with --skill', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'install', '--all', '--skill', 'alpha'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Use either --all or --skill, not both.')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an unknown --skill name', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'install', '--skill', 'missing'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Unknown skill "missing". Available skills: alpha, gamma, zeta'
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects --skill without a value', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'install', '--skill'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Missing required --skill')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects --json for a real (non-dry-run) install', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'install', '--skill', 'alpha', '--json'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
id: 'local',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid_argument',
|
||||
message:
|
||||
"orca skills install --json only supports --dry-run. Real installs stream npx's " +
|
||||
"own output, which isn't JSON."
|
||||
},
|
||||
_meta: { runtimeId: null }
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prints the resolved install command without running it for --dry-run', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' +
|
||||
'Rerun without --dry-run to install now.\n'
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives dry-run --json a stable schema', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'install', '--skill', 'legacy-alpha', '--dry-run', '--json'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
command:
|
||||
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y',
|
||||
skills: ['alpha'],
|
||||
global: true,
|
||||
executed: false
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('drops --global for --local in the dry-run command and JSON', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'install', '--skill', 'alpha', '--local', '--dry-run'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y\n\n' +
|
||||
'Rerun without --dry-run to install now.\n'
|
||||
)
|
||||
|
||||
stdoutSpy.mockClear()
|
||||
await main(
|
||||
['skills', 'install', '--skill', 'alpha', '--local', '--dry-run', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
command:
|
||||
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y',
|
||||
skills: ['alpha'],
|
||||
global: false,
|
||||
executed: false
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('runs npx without --global for --local', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'install', '--skill', 'alpha', '--local'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
[
|
||||
'--yes',
|
||||
'skills',
|
||||
'add',
|
||||
'https://github.com/stablyai/orca',
|
||||
'--skill',
|
||||
'alpha',
|
||||
'--agent',
|
||||
'claude-code',
|
||||
'--agent',
|
||||
'universal',
|
||||
'-y'
|
||||
],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
)
|
||||
})
|
||||
|
||||
it('routes a resolved Windows .cmd shim through cmd.exe', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe')
|
||||
resolveCliCommandMock.mockReturnValue('C:\\Program Files\\nodejs\\npx.cmd')
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Windows\\System32\\cmd.exe',
|
||||
[
|
||||
'/d',
|
||||
'/c',
|
||||
'C:\\Program Files\\nodejs\\npx.cmd',
|
||||
'--yes',
|
||||
'skills',
|
||||
'add',
|
||||
'https://github.com/stablyai/orca',
|
||||
'--skill',
|
||||
'alpha',
|
||||
'--global',
|
||||
'--agent',
|
||||
'claude-code',
|
||||
'--agent',
|
||||
'universal',
|
||||
'-y'
|
||||
],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
)
|
||||
})
|
||||
|
||||
it('spawns a resolved npx path directly when it is not a .cmd shim', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
resolveCliCommandMock.mockReturnValue('C:\\Program Files\\nodejs\\npx.exe')
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
// Why: an .exe shim must stay a direct spawn so a missing npx still raises
|
||||
// ENOENT on the child instead of hiding inside cmd.exe's own exit code.
|
||||
expect(spawnMock.mock.calls[0]?.[0]).toBe('C:\\Program Files\\nodejs\\npx.exe')
|
||||
expect(spawnMock.mock.calls[0]?.[1]?.[0]).toBe('--yes')
|
||||
})
|
||||
|
||||
it('resolves a legacy topic alias to the canonical skill name for install', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'install', '--skill', 'legacy-alpha'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
[
|
||||
'--yes',
|
||||
'skills',
|
||||
'add',
|
||||
'https://github.com/stablyai/orca',
|
||||
'--skill',
|
||||
'alpha',
|
||||
'--global',
|
||||
'--agent',
|
||||
'claude-code',
|
||||
'--agent',
|
||||
'universal',
|
||||
'-y'
|
||||
],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
)
|
||||
})
|
||||
|
||||
it('runs npx for --all and forwards its exit code', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'install', '--all'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 1, null)
|
||||
await resultPromise
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
[
|
||||
'--yes',
|
||||
'skills',
|
||||
'add',
|
||||
'https://github.com/stablyai/orca',
|
||||
'--skill',
|
||||
'alpha',
|
||||
'--skill',
|
||||
'gamma',
|
||||
'--skill',
|
||||
'zeta',
|
||||
'--global',
|
||||
'--agent',
|
||||
'claude-code',
|
||||
'--agent',
|
||||
'universal',
|
||||
'-y'
|
||||
],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('propagates a spawn error as a nonzero exit', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('error', new Error('spawn npx ENOENT'))
|
||||
await resultPromise
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Could not run npx: spawn npx ENOENT. Install Node.js and ensure npx is on PATH.'
|
||||
)
|
||||
})
|
||||
|
||||
it('lists updatable skills when no --skill/--all is given', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'update'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
[
|
||||
'Choose one or more skills to update:',
|
||||
' alpha',
|
||||
' gamma',
|
||||
' zeta',
|
||||
'',
|
||||
'Usage: orca skills update --skill <name> [--skill <name> ...]',
|
||||
' or: orca skills update --all',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prints the resolved update command without running it for --dry-run', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'update', '--skill', 'legacy-alpha', '--dry-run'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
'npx --yes skills update alpha --global -y\n\nRerun without --dry-run to update now.\n'
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects project scope for --local on update', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(
|
||||
['skills', 'update', '--skill', 'alpha', '--local', '--dry-run', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
command: 'npx --yes skills update alpha --project -y',
|
||||
skills: ['alpha'],
|
||||
global: false,
|
||||
executed: false
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('runs local updates with explicit project scope', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'update', '--skill', 'alpha', '--local'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
['--yes', 'skills', 'update', 'alpha', '--project', '-y'],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a real run when the shell forwards orca to the Orca host', async () => {
|
||||
vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt')
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
|
||||
|
||||
// Why: the SSH relay and WSL bridge run argv on the Orca host, so a real
|
||||
// install there would silently skip the machine the user is sitting on.
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it')
|
||||
})
|
||||
|
||||
it('refuses --dry-run through the host-forwarding shim too', async () => {
|
||||
vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt')
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo')
|
||||
|
||||
// Why: the targets are resolved from THIS host's agents, so a command printed
|
||||
// here would name the wrong machine's agents. Point at the target instead.
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it')
|
||||
})
|
||||
|
||||
it('puts the resolved npx directory on the child PATH', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
resolveCliCommandMock.mockReturnValue('/home/alice/.nvm/versions/node/v22/bin/npx')
|
||||
vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
// Why: npx is an `env node` script, so an off-PATH npx exits 127 with no
|
||||
// 'error' event unless node ships alongside it on the child's PATH.
|
||||
const env = spawnMock.mock.calls[0]?.[2]?.env
|
||||
// Why: the child still needs the inherited PATH and the rest of the parent
|
||||
// environment; replacing it outright breaks git, node, HOME and npm config.
|
||||
expect(env?.PATH).toBe(
|
||||
`/home/alice/.nvm/versions/node/v22/bin${delimiter}/usr/bin${delimiter}/bin`
|
||||
)
|
||||
expect(env?.HOME ?? env?.USERPROFILE).toBe(process.env.HOME ?? process.env.USERPROFILE)
|
||||
})
|
||||
|
||||
it('reports a Windows npx path cmd.exe would reinterpret', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe')
|
||||
resolveCliCommandMock.mockReturnValue('C:\\Users\\A&B\\npx.cmd')
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('cmd.exe would reinterpret')
|
||||
})
|
||||
|
||||
it('never puts the current directory on the child PATH when npx is unresolvable', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
// Why: resolveCliCommand returns the bare name when it finds nothing, and
|
||||
// dirname('npx') is '.', which would run ./npx out of the caller's checkout.
|
||||
resolveCliCommandMock.mockReturnValue('npx')
|
||||
vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
// Why: assert the constructed value, not the ambient one — a dev PATH with a
|
||||
// trailing separator carries its own '' entry and would fake a failure here.
|
||||
expect(spawnMock.mock.calls[0]?.[2]?.env?.PATH).toBe(`/usr/bin${delimiter}/bin`)
|
||||
})
|
||||
|
||||
it('refuses to install when Orca detects no agent, instead of targeting them all', async () => {
|
||||
detectCommandsMock.mockReturnValue(new Set<string>())
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
|
||||
|
||||
// Why: `skills add -y` with nothing detected installs into every agent it
|
||||
// knows (~75), creating config dirs for agents the host does not have.
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('No coding agent detected')
|
||||
})
|
||||
|
||||
it('honours an explicit --agent list without probing the host', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
detectCommandsMock.mockReturnValue(new Set<string>())
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(
|
||||
['skills', 'install', '--skill', 'alpha', '--agent', 'codex, claude-code ,codex'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
const argv = spawnMock.mock.calls[0]?.[1] ?? []
|
||||
const agents = argv.filter((_: string, i: number) => argv[i - 1] === '--agent')
|
||||
// Why: trimmed and de-duplicated, and detection is not consulted at all.
|
||||
expect(agents).toEqual(['codex', 'claude-code'])
|
||||
expect(detectCommandsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps detected agents onto the skills CLI namespace, not Orca ids', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
detectCommandsMock.mockReturnValue(new Set<string>(['claude', 'cursor-agent', 'rovo']))
|
||||
|
||||
await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo')
|
||||
|
||||
// Why: `skills add` exits 1 on an unknown --agent, and the ids differ —
|
||||
// Orca's `claude` is `claude-code` and its `rovo` is `rovodev`.
|
||||
expect(stdoutText(stdoutSpy)).toContain(
|
||||
'--agent claude-code --agent cursor --agent rovodev --agent universal'
|
||||
)
|
||||
})
|
||||
|
||||
it('never sends --agent for an update, and never refuses on a bare host', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
// Why: update refreshes what is already placed, so the no-agent refusal that
|
||||
// guards install must not reach it.
|
||||
detectCommandsMock.mockReturnValue(new Set<string>())
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'update', '--skill', 'alpha'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
// Why: update refreshes what is already placed; it chooses no new targets.
|
||||
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--agent')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a bare --agent', ['skills', 'install', '--skill', 'alpha', '--agent']],
|
||||
['an empty --agent', ['skills', 'install', '--skill', 'alpha', '--agent', '']],
|
||||
['a separator-only --agent', ['skills', 'install', '--skill', 'alpha', '--agent', ' , ,']]
|
||||
])('rejects %s instead of installing to every agent', async (_label, argv) => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(argv, '/tmp/repo')
|
||||
|
||||
// Why: an --agent that resolves to nothing must not fall back to detection or
|
||||
// emit no --agent at all — the latter restores the ~75-agent install.
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Missing required --agent')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a dash-leading value', ['skills', 'install', '--skill', 'alpha', '--agent', '-y']],
|
||||
['an inline dash value', ['skills', 'install', '--skill', 'alpha', '--agent=--copy']],
|
||||
['a value with a space', ['skills', 'install', '--skill', 'alpha', '--agent', 'a b']]
|
||||
])('rejects %s the skills CLI would silently drop', async (_label, argv) => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(argv, '/tmp/repo')
|
||||
|
||||
// Why: the skills CLI drops such a value, leaving it with no target — the same
|
||||
// all-agents install as omitting --agent entirely.
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Invalid --agent value')
|
||||
})
|
||||
|
||||
it('reports forwarding, not missing agents, when a forwarded host detects none', async () => {
|
||||
vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt')
|
||||
detectCommandsMock.mockReturnValue(new Set<string>())
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
|
||||
|
||||
// Why: resolving targets first would hide the forwarding problem behind a
|
||||
// no-agent error about the wrong machine.
|
||||
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it')
|
||||
})
|
||||
|
||||
it('documents --agent for skills install rather than the terminal-launch flag', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'install', '--help'], '/tmp/repo')
|
||||
|
||||
const help = String(logSpy.mock.calls[0]?.[0])
|
||||
expect(help).toContain('--agent <names>')
|
||||
expect(help).not.toContain('Launch a known TUI agent')
|
||||
})
|
||||
|
||||
it('accumulates a repeated --skill instead of keeping only the last one', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(
|
||||
['skills', 'install', '--skill', 'zeta', '--skill', 'alpha'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
// Why: the documented primary invocation. Dropping 'skill' from the
|
||||
// repeatable-flag set silently installs one skill instead of two.
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
[
|
||||
'--yes',
|
||||
'skills',
|
||||
'add',
|
||||
'https://github.com/stablyai/orca',
|
||||
'--skill',
|
||||
'alpha',
|
||||
'--skill',
|
||||
'zeta',
|
||||
'--global',
|
||||
'--agent',
|
||||
'claude-code',
|
||||
'--agent',
|
||||
'universal',
|
||||
'-y'
|
||||
],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
)
|
||||
})
|
||||
|
||||
it('collapses an alias and its canonical name into one --skill', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(
|
||||
['skills', 'install', '--skill', 'alpha', '--skill', 'legacy-alpha', '--dry-run'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' +
|
||||
'Rerun without --dry-run to install now.\n'
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the command it is about to run on stderr', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 0, null)
|
||||
await resultPromise
|
||||
|
||||
// Why: stdout belongs to the child, so this record has to go to stderr.
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
'Running: npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('runs npx skills update for --all and forwards its exit code', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValue(child)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const resultPromise = main(['skills', 'update', '--all'], '/tmp/repo')
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
|
||||
child.emit('exit', 2, null)
|
||||
await resultPromise
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
['--yes', 'skills', 'update', 'alpha', 'gamma', 'zeta', '--global', '-y'],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
)
|
||||
expect(process.exitCode).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects --json for a real (non-dry-run) update', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'update', '--skill', 'alpha', '--json'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
id: 'local',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid_argument',
|
||||
message:
|
||||
"orca skills update --json only supports --dry-run. Real updates stream npx's " +
|
||||
"own output, which isn't JSON."
|
||||
},
|
||||
_meta: { runtimeId: null }
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function stdoutText(spy: ReturnType<typeof vi.spyOn>): string {
|
||||
return spy.mock.calls.map((call) => String(call[0])).join('')
|
||||
}
|
||||
|
||||
function createFakeChild(): EventEmitter {
|
||||
return new EventEmitter()
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['orchestration', 'run-list'],
|
||||
summary: 'List lightweight orchestration Runs',
|
||||
usage: 'orca orchestration run-list [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS]
|
||||
usage: 'orca orchestration run-list [--limit <n>] [--cursor <cursor>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'cursor']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-show'],
|
||||
|
||||
+62
-1
@@ -9,7 +9,8 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
notes: [
|
||||
'Reads bundled guide metadata locally without contacting the Orca runtime.',
|
||||
'With --json, prints a topics array of canonical names and one-line descriptions.'
|
||||
'With --json, prints a topics array of canonical names and one-line descriptions.',
|
||||
'Use `orca skills get <name>` for the full guide, or `orca skills install` to install skills.'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -25,5 +26,65 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [
|
||||
'Use --json for a deterministic object containing canonical topic metadata and content.'
|
||||
],
|
||||
examples: ['orca skills get orca-cli', 'orca skills get orchestration --full']
|
||||
},
|
||||
{
|
||||
path: ['skills', 'install'],
|
||||
summary: 'Install bundled Orca skills via the community skills CLI',
|
||||
usage:
|
||||
'orca skills install [--skill <name>]... [--all] [--agent <name>[,<name>]] ' +
|
||||
'[--local] [--dry-run] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'agent', 'local', 'dry-run'],
|
||||
notes: [
|
||||
'Reads the bundled skill registry locally without contacting the Orca runtime.',
|
||||
'Resolves to the same `npx skills add <repo> --skill <name> ...` command used by ' +
|
||||
'Orca Settings, plus the non-interactive flags an unattended host needs ' +
|
||||
'(`npx --yes` and `-y`), then runs it and forwards its output and exit code.',
|
||||
'Installs globally (all projects, adds --global) by default. Use --local to install ' +
|
||||
'into the current project instead.',
|
||||
'Targets the coding agents Orca detects on this host, plus the shared ' +
|
||||
'.agents/skills directory. Without an explicit target the skills CLI installs ' +
|
||||
'into every agent it knows about, which litters a host with config ' +
|
||||
'directories for agents it does not have.',
|
||||
'Use --agent <name>[,<name>...] to choose targets yourself, or --agent universal ' +
|
||||
'for the shared directory alone. Required when Orca detects no agent.',
|
||||
'Use --dry-run to print the resolved command without running it.',
|
||||
'With --json, the skill listing and --dry-run emit JSON; a real install streams ' +
|
||||
"npx's own non-JSON output live and rejects --json.",
|
||||
'Omit --skill and --all to list installable skill names.',
|
||||
'Intended for headless hosts (SSH, containers, CI) with no desktop Settings UI to copy the install command from.'
|
||||
],
|
||||
examples: [
|
||||
'orca skills install',
|
||||
'orca skills install --skill orca-cli --skill orchestration',
|
||||
'orca skills install --skill orca-cli --local',
|
||||
'orca skills install --skill orca-cli --agent claude-code,codex',
|
||||
'orca skills install --all --dry-run'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['skills', 'update'],
|
||||
summary: 'Update already-installed Orca skills via the community skills CLI',
|
||||
usage: 'orca skills update [--skill <name>]... [--all] [--local] [--dry-run] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'local', 'dry-run'],
|
||||
notes: [
|
||||
'Reads the bundled skill registry locally without contacting the Orca runtime.',
|
||||
'Resolves to the same `npx skills update <names...>` command used by Orca Settings, ' +
|
||||
'plus the non-interactive flags an unattended host needs (`npx --yes` and `-y`), ' +
|
||||
'then runs it and forwards its output and exit code.',
|
||||
'Updates the global install (all projects, adds --global) by default. Use --local to ' +
|
||||
'update the current project instead.',
|
||||
'Only refreshes skills that are already installed; use `orca skills install` first.',
|
||||
'Use --dry-run to print the resolved command without running it.',
|
||||
'With --json, the skill listing and --dry-run emit JSON; a real update streams ' +
|
||||
"npx's own non-JSON output live and rejects --json.",
|
||||
'Omit --skill and --all to list updatable skill names.',
|
||||
'Intended for headless hosts (SSH, containers, CI) with no desktop Settings UI to copy the update command from.'
|
||||
],
|
||||
examples: [
|
||||
'orca skills update',
|
||||
'orca skills update --skill orca-cli --skill orchestration',
|
||||
'orca skills update --skill orca-cli --local',
|
||||
'orca skills update --all --dry-run'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { PtyBackgroundStreamEvent } from '../providers/types'
|
||||
import { combineUnsubscribes } from './combine-unsubscribes'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
|
||||
|
||||
export class DaemonPtyAdapterSubscriptionFanout {
|
||||
private unsubscribers: (() => void)[] = []
|
||||
private dataListeners: ((payload: DaemonPtyRouterDataEvent) => void)[] = []
|
||||
private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = []
|
||||
|
||||
constructor(
|
||||
private readonly adapters: readonly DaemonPtyAdapter[],
|
||||
onAdapterExit: (id: string) => void
|
||||
) {
|
||||
for (const adapter of adapters) {
|
||||
this.unsubscribers.push(
|
||||
adapter.onData((payload) => {
|
||||
for (const listener of this.dataListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
}),
|
||||
adapter.onExit((payload) => {
|
||||
onAdapterExit(payload.id)
|
||||
for (const listener of this.exitListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void {
|
||||
this.dataListeners.push(callback)
|
||||
return () => {
|
||||
const index = this.dataListeners.indexOf(callback)
|
||||
if (index !== -1) {
|
||||
this.dataListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
|
||||
return combineUnsubscribes(
|
||||
this.adapters.map((adapter) => adapter.onBackgroundStreamEvent(callback))
|
||||
)
|
||||
}
|
||||
|
||||
// Why: main subscribes on the routed provider, so without this the dead-endpoint
|
||||
// fan-out never reaches the renderer and only the written pane recovers (STA-2373).
|
||||
onWriteUnavailable(callback: (payload: { id: string }) => void): () => void {
|
||||
return combineUnsubscribes(this.adapters.map((adapter) => adapter.onWriteUnavailable(callback)))
|
||||
}
|
||||
|
||||
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
|
||||
this.exitListeners.push(callback)
|
||||
return () => {
|
||||
const index = this.exitListeners.indexOf(callback)
|
||||
if (index !== -1) {
|
||||
this.exitListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const unsubscribe of this.unsubscribers.splice(0)) {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
|
||||
export type DaemonPtyRouterDataEvent = {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
}
|
||||
|
||||
export type DaemonPtyRouterExitEvent = {
|
||||
id: string
|
||||
code: number
|
||||
incarnationId?: PtyIncarnationId
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { combineUnsubscribes } from './combine-unsubscribes'
|
||||
import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout'
|
||||
import type {
|
||||
IPtyProvider,
|
||||
PtyBackgroundStreamEvent,
|
||||
@@ -8,41 +8,23 @@ import type {
|
||||
PtySpawnOptions,
|
||||
PtySpawnResult
|
||||
} from '../providers/types'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import { probePtyOwners } from './daemon-pty-liveness-probe'
|
||||
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
|
||||
|
||||
type PtyDataListener = Parameters<IPtyProvider['onData']>[0]
|
||||
type PtyExitListener = Parameters<IPtyProvider['onExit']>[0]
|
||||
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
|
||||
|
||||
export class DaemonPtyRouter implements IPtyProvider {
|
||||
private current: DaemonPtyAdapter
|
||||
private legacy: DaemonPtyAdapter[]
|
||||
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
|
||||
private unsubscribers: (() => void)[] = []
|
||||
private dataListeners: PtyDataListener[] = []
|
||||
private exitListeners: PtyExitListener[] = []
|
||||
private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout
|
||||
|
||||
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
|
||||
this.current = opts.current
|
||||
this.legacy = opts.legacy
|
||||
|
||||
for (const adapter of this.allAdapters()) {
|
||||
this.unsubscribers.push(
|
||||
adapter.onData((payload) => {
|
||||
for (const listener of this.dataListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
}),
|
||||
adapter.onExit((payload) => {
|
||||
this.sessionAdapters.delete(payload.id)
|
||||
for (const listener of this.exitListeners) {
|
||||
listener(payload)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(this.allAdapters(), (id) => {
|
||||
this.sessionAdapters.delete(id)
|
||||
})
|
||||
}
|
||||
|
||||
async discoverLegacySessions(): Promise<void> {
|
||||
@@ -224,52 +206,24 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
return this.current.getProfiles()
|
||||
}
|
||||
|
||||
onData(
|
||||
callback: (payload: {
|
||||
id: string
|
||||
data: string
|
||||
sequenceChars?: number
|
||||
transformed?: boolean
|
||||
seq?: number
|
||||
}) => void
|
||||
): () => void {
|
||||
this.dataListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.dataListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
this.dataListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void {
|
||||
return this.subscriptions.onData(callback)
|
||||
}
|
||||
|
||||
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
|
||||
return combineUnsubscribes(
|
||||
this.allAdapters().map((adapter) => adapter.onBackgroundStreamEvent(callback))
|
||||
)
|
||||
return this.subscriptions.onBackgroundStreamEvent(callback)
|
||||
}
|
||||
|
||||
// Why: main subscribes on the routed provider, so without this the dead-endpoint
|
||||
// fan-out never reaches the renderer and only the written pane recovers (STA-2373).
|
||||
onWriteUnavailable(callback: (payload: { id: string }) => void): () => void {
|
||||
return combineUnsubscribes(
|
||||
this.allAdapters().map((adapter) => adapter.onWriteUnavailable(callback))
|
||||
)
|
||||
return this.subscriptions.onWriteUnavailable(callback)
|
||||
}
|
||||
|
||||
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
return () => {}
|
||||
onReplay(callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
return this.subscriptions.onReplay(callback)
|
||||
}
|
||||
|
||||
onExit(
|
||||
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
|
||||
): () => void {
|
||||
this.exitListeners.push(callback)
|
||||
return () => {
|
||||
const idx = this.exitListeners.indexOf(callback)
|
||||
if (idx !== -1) {
|
||||
this.exitListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
|
||||
return this.subscriptions.onExit(callback)
|
||||
}
|
||||
|
||||
ackColdRestore(sessionId: string): void {
|
||||
@@ -307,9 +261,7 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const unsubscribe of this.unsubscribers.splice(0)) {
|
||||
unsubscribe()
|
||||
}
|
||||
this.subscriptions.dispose()
|
||||
for (const adapter of this.allAdapters()) {
|
||||
adapter.dispose()
|
||||
}
|
||||
@@ -323,15 +275,11 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
// Without this, each restart leaked a router instance pinned by the legacy
|
||||
// adapters' listener arrays (one pair per adapter per restart).
|
||||
disposeRouterOnly(): void {
|
||||
for (const unsubscribe of this.unsubscribers.splice(0)) {
|
||||
unsubscribe()
|
||||
}
|
||||
this.subscriptions.dispose()
|
||||
}
|
||||
|
||||
async disconnectOnly(): Promise<void> {
|
||||
for (const unsubscribe of this.unsubscribers.splice(0)) {
|
||||
unsubscribe()
|
||||
}
|
||||
this.subscriptions.dispose()
|
||||
await Promise.all([...this.allAdapters()].map((adapter) => adapter.disconnectOnly()))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonUtf8ByteLength } from './json-utf8-byte-length'
|
||||
|
||||
describe('jsonUtf8ByteLength', () => {
|
||||
it('matches JSON.stringify for escapes, Unicode, surrogates, and nested values', () => {
|
||||
const values: unknown[] = [
|
||||
'',
|
||||
'"\\\b\t\n\f\r\u0000\u001f',
|
||||
'plain ASCII',
|
||||
'é漢😀',
|
||||
'\ud800 lone high \udc00 lone low',
|
||||
{
|
||||
omitted: undefined,
|
||||
finite: -1.25e100,
|
||||
nonFinite: Number.POSITIVE_INFINITY,
|
||||
nested: ['😀', undefined, null, { control: '\u0001' }]
|
||||
}
|
||||
]
|
||||
|
||||
for (const value of values) {
|
||||
const json = JSON.stringify(value)
|
||||
expect(jsonUtf8ByteLength(value)).toBe(Buffer.byteLength(json, 'utf8'))
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects the same unsupported structural values as JSON.stringify', () => {
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
|
||||
expect(() => jsonUtf8ByteLength(circular)).toThrow('circular')
|
||||
expect(() => jsonUtf8ByteLength(1n)).toThrow('BigInt')
|
||||
})
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
function jsonStringUtf8Bytes(value: string): number {
|
||||
let bytes = 2
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index)
|
||||
if (codeUnit === 0x22 || codeUnit === 0x5c || codeUnit === 0x08 || codeUnit === 0x09) {
|
||||
bytes += 2
|
||||
} else if (codeUnit === 0x0a || codeUnit === 0x0c || codeUnit === 0x0d) {
|
||||
bytes += 2
|
||||
} else if (codeUnit < 0x20) {
|
||||
bytes += 6
|
||||
} else if (codeUnit < 0x80) {
|
||||
bytes += 1
|
||||
} else if (codeUnit < 0x800) {
|
||||
bytes += 2
|
||||
} else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4
|
||||
index += 1
|
||||
} else {
|
||||
bytes += 6
|
||||
}
|
||||
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
bytes += 6
|
||||
} else {
|
||||
bytes += 3
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function jsonUtf8ByteLength(value: unknown): number {
|
||||
const activeObjects = new Set<object>()
|
||||
|
||||
const measure = (current: unknown, arrayElement: boolean): number | null => {
|
||||
if (current === null) {
|
||||
return 4
|
||||
}
|
||||
switch (typeof current) {
|
||||
case 'string':
|
||||
return jsonStringUtf8Bytes(current)
|
||||
case 'boolean':
|
||||
return current ? 4 : 5
|
||||
case 'number':
|
||||
return Number.isFinite(current) ? JSON.stringify(current).length : 4
|
||||
case 'undefined':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
return arrayElement ? 4 : null
|
||||
case 'bigint':
|
||||
throw new TypeError('Do not know how to serialize a BigInt')
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
|
||||
const object = current as object
|
||||
if (activeObjects.has(object)) {
|
||||
throw new TypeError('Converting circular structure to JSON')
|
||||
}
|
||||
activeObjects.add(object)
|
||||
try {
|
||||
if (Array.isArray(object)) {
|
||||
let bytes = 2
|
||||
for (let index = 0; index < object.length; index += 1) {
|
||||
if (index > 0) {
|
||||
bytes += 1
|
||||
}
|
||||
bytes += measure(object[index], true) ?? 4
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
let bytes = 2
|
||||
let entries = 0
|
||||
for (const key of Object.keys(object)) {
|
||||
const propertyBytes = measure((object as Record<string, unknown>)[key], false)
|
||||
if (propertyBytes === null) {
|
||||
continue
|
||||
}
|
||||
bytes += (entries > 0 ? 1 : 0) + jsonStringUtf8Bytes(key) + 1 + propertyBytes
|
||||
entries += 1
|
||||
}
|
||||
return bytes
|
||||
} finally {
|
||||
activeObjects.delete(object)
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = measure(value, false)
|
||||
if (bytes === null) {
|
||||
throw new TypeError('Value is not JSON serializable')
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSnapshot } from './types'
|
||||
import { serializeTerminalCheckpointWithinLimit } from './terminal-checkpoint-serializer'
|
||||
|
||||
function snapshot(overrides: Partial<TerminalSnapshot> = {}): TerminalSnapshot {
|
||||
return {
|
||||
snapshotAnsi: 'visible',
|
||||
scrollbackAnsi: '',
|
||||
rehydrateSequences: '',
|
||||
cwd: '/workspace',
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
},
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
scrollbackLines: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
cwd: '/workspace',
|
||||
generation: 1,
|
||||
checkpointedAt: '2026-07-29T00:00:00.000Z'
|
||||
}
|
||||
|
||||
describe('terminal checkpoint serializer', () => {
|
||||
it('matches JSON.stringify exactly at the UTF-8 byte limit', async () => {
|
||||
const input = snapshot({
|
||||
snapshotAnsi: `é漢😀${String.fromCharCode(0xd800, 0xdc00)}"\\${String.fromCharCode(
|
||||
0x00,
|
||||
0x08,
|
||||
0x09,
|
||||
0x0a,
|
||||
0x0c,
|
||||
0x0d,
|
||||
0x1f,
|
||||
0xd800,
|
||||
0xdc00
|
||||
)}`,
|
||||
oscLinks: [{ row: 0, startCol: 0, endCol: 1, uri: 'https://example.com/😀\n' }]
|
||||
})
|
||||
const expected = JSON.stringify({
|
||||
snapshotAnsi: input.snapshotAnsi,
|
||||
scrollbackAnsi: input.scrollbackAnsi,
|
||||
oscLinks: input.oscLinks,
|
||||
rehydrateSequences: input.rehydrateSequences,
|
||||
cwd: metadata.cwd,
|
||||
cols: input.cols,
|
||||
rows: input.rows,
|
||||
modes: input.modes,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
generation: metadata.generation,
|
||||
checkpointedAt: metadata.checkpointedAt
|
||||
})
|
||||
const exactBytes = Buffer.byteLength(expected, 'utf8')
|
||||
|
||||
await expect(serializeTerminalCheckpointWithinLimit(input, metadata, exactBytes)).resolves.toBe(
|
||||
expected
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects multibyte input whose code-unit length fits under the byte cap', async () => {
|
||||
const input = snapshot({
|
||||
scrollbackAnsi: 'é\r\n'.repeat(100),
|
||||
scrollbackLines: 100
|
||||
})
|
||||
const expected = JSON.stringify({
|
||||
snapshotAnsi: input.snapshotAnsi,
|
||||
scrollbackAnsi: input.scrollbackAnsi,
|
||||
oscLinks: input.oscLinks,
|
||||
rehydrateSequences: input.rehydrateSequences,
|
||||
cwd: metadata.cwd,
|
||||
cols: input.cols,
|
||||
rows: input.rows,
|
||||
modes: input.modes,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
generation: metadata.generation,
|
||||
checkpointedAt: metadata.checkpointedAt
|
||||
})
|
||||
const maxBytes = expected.length + 1
|
||||
|
||||
expect(expected.length).toBeLessThan(maxBytes)
|
||||
expect(Buffer.byteLength(expected, 'utf8')).toBeGreaterThan(maxBytes)
|
||||
|
||||
const serialized = await serializeTerminalCheckpointWithinLimit(input, metadata, maxBytes)
|
||||
|
||||
expect(serialized).not.toBe(expected)
|
||||
expect(Buffer.byteLength(serialized, 'utf8')).toBeLessThanOrEqual(maxBytes)
|
||||
})
|
||||
|
||||
it('does not materialize and rescan a passing candidate', async () => {
|
||||
let reads = 0
|
||||
const input = snapshot()
|
||||
Object.defineProperty(input, 'snapshotAnsi', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads += 1
|
||||
return 'visible'
|
||||
}
|
||||
})
|
||||
const stringify = vi.spyOn(JSON, 'stringify')
|
||||
const byteLength = vi.spyOn(Buffer, 'byteLength')
|
||||
|
||||
try {
|
||||
await serializeTerminalCheckpointWithinLimit(input, metadata, 20 * 1024)
|
||||
|
||||
expect({
|
||||
fullCandidateStringifies: stringify.mock.calls.filter(
|
||||
([value]) => typeof value === 'object' && value !== null
|
||||
).length,
|
||||
byteLengthCalls: byteLength.mock.calls.length,
|
||||
snapshotReads: reads
|
||||
}).toEqual({ fullCandidateStringifies: 0, byteLengthCalls: 0, snapshotReads: 1 })
|
||||
} finally {
|
||||
stringify.mockRestore()
|
||||
byteLength.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an oversized escaped candidate without materializing it', async () => {
|
||||
const oversized = String.fromCharCode(0).repeat(100_000)
|
||||
const stringify = vi.spyOn(JSON, 'stringify')
|
||||
|
||||
try {
|
||||
await serializeTerminalCheckpointWithinLimit(
|
||||
snapshot({ snapshotAnsi: oversized }),
|
||||
metadata,
|
||||
512
|
||||
)
|
||||
|
||||
const materializedOversizedCandidate = stringify.mock.calls.some(([value]) => {
|
||||
return (value as { snapshotAnsi?: unknown })?.snapshotAnsi === oversized
|
||||
})
|
||||
expect(materializedOversizedCandidate).toBe(false)
|
||||
} finally {
|
||||
stringify.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TerminalCheckpointFile, TerminalSnapshot } from './types'
|
||||
import { ColdRestoreReplayWriter } from './cold-restore-replay-writer'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { jsonUtf8ByteLength } from './json-utf8-byte-length'
|
||||
|
||||
type CheckpointMetadata = {
|
||||
cwd: string | null
|
||||
@@ -32,15 +31,187 @@ function checkpointFile(
|
||||
}
|
||||
}
|
||||
|
||||
class BoundedJsonWriter {
|
||||
private output = ''
|
||||
private chunk = ''
|
||||
private bytes = 0
|
||||
private exceeded = false
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
append(value: string, bytes: number): boolean {
|
||||
if (this.bytes + bytes > this.maxBytes) {
|
||||
this.exceeded = true
|
||||
this.output = ''
|
||||
this.chunk = ''
|
||||
return false
|
||||
}
|
||||
this.bytes += bytes
|
||||
this.chunk += value
|
||||
if (this.chunk.length >= 16 * 1024) {
|
||||
this.output += this.chunk
|
||||
this.chunk = ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
result(): string | null {
|
||||
return this.exceeded ? null : this.output + this.chunk
|
||||
}
|
||||
}
|
||||
|
||||
function escapedCodeUnit(codeUnit: number): string | null {
|
||||
switch (codeUnit) {
|
||||
case 0x08:
|
||||
return '\\b'
|
||||
case 0x09:
|
||||
return '\\t'
|
||||
case 0x0a:
|
||||
return '\\n'
|
||||
case 0x0c:
|
||||
return '\\f'
|
||||
case 0x0d:
|
||||
return '\\r'
|
||||
case 0x22:
|
||||
return '\\"'
|
||||
case 0x5c:
|
||||
return '\\\\'
|
||||
default:
|
||||
return codeUnit < 0x20 ? `\\u${codeUnit.toString(16).padStart(4, '0')}` : null
|
||||
}
|
||||
}
|
||||
|
||||
function appendJsonString(writer: BoundedJsonWriter, value: string): boolean {
|
||||
if (!writer.append('"', 1)) {
|
||||
return false
|
||||
}
|
||||
let spanStart = 0
|
||||
let spanBytes = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index)
|
||||
let escaped = escapedCodeUnit(codeUnit)
|
||||
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
spanBytes += 4
|
||||
index += 1
|
||||
} else {
|
||||
escaped = `\\u${codeUnit.toString(16)}`
|
||||
}
|
||||
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
escaped = `\\u${codeUnit.toString(16)}`
|
||||
} else if (escaped === null) {
|
||||
spanBytes += codeUnit < 0x80 ? 1 : codeUnit < 0x800 ? 2 : 3
|
||||
}
|
||||
|
||||
if (escaped !== null) {
|
||||
if (
|
||||
(index > spanStart && !writer.append(value.slice(spanStart, index), spanBytes)) ||
|
||||
!writer.append(escaped, escaped.length)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
spanStart = index + 1
|
||||
spanBytes = 0
|
||||
} else if (index + 1 - spanStart >= 16 * 1024) {
|
||||
if (!writer.append(value.slice(spanStart, index + 1), spanBytes)) {
|
||||
return false
|
||||
}
|
||||
spanStart = index + 1
|
||||
spanBytes = 0
|
||||
}
|
||||
}
|
||||
if (spanStart < value.length && !writer.append(value.slice(spanStart), spanBytes)) {
|
||||
return false
|
||||
}
|
||||
return writer.append('"', 1)
|
||||
}
|
||||
|
||||
function omittedByJson(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'function' || typeof value === 'symbol'
|
||||
}
|
||||
|
||||
function appendJsonValue(
|
||||
writer: BoundedJsonWriter,
|
||||
value: unknown,
|
||||
activeObjects: Set<object>
|
||||
): boolean {
|
||||
if (value === null) {
|
||||
return writer.append('null', 4)
|
||||
}
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
return appendJsonString(writer, value)
|
||||
case 'boolean':
|
||||
return writer.append(value ? 'true' : 'false', value ? 4 : 5)
|
||||
case 'number': {
|
||||
const json = Number.isFinite(value) ? JSON.stringify(value) : 'null'
|
||||
return writer.append(json, json.length)
|
||||
}
|
||||
case 'bigint':
|
||||
throw new TypeError('Do not know how to serialize a BigInt')
|
||||
case 'undefined':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
return false
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
|
||||
if (activeObjects.has(value)) {
|
||||
throw new TypeError('Converting circular structure to JSON')
|
||||
}
|
||||
activeObjects.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (!writer.append('[', 1)) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (index > 0 && !writer.append(',', 1)) {
|
||||
return false
|
||||
}
|
||||
const entry = value[index]
|
||||
if (omittedByJson(entry)) {
|
||||
if (!writer.append('null', 4)) {
|
||||
return false
|
||||
}
|
||||
} else if (!appendJsonValue(writer, entry, activeObjects)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return writer.append(']', 1)
|
||||
}
|
||||
|
||||
if (!writer.append('{', 1)) {
|
||||
return false
|
||||
}
|
||||
let entries = 0
|
||||
for (const key of Object.keys(value)) {
|
||||
const entry = (value as Record<string, unknown>)[key]
|
||||
if (omittedByJson(entry)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
(entries > 0 && !writer.append(',', 1)) ||
|
||||
!appendJsonString(writer, key) ||
|
||||
!writer.append(':', 1) ||
|
||||
!appendJsonValue(writer, entry, activeObjects)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
entries += 1
|
||||
}
|
||||
return writer.append('}', 1)
|
||||
} finally {
|
||||
activeObjects.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyWithinLimit(checkpoint: TerminalCheckpointFile, maxBytes: number): string | null {
|
||||
if (jsonUtf8ByteLength(checkpoint) > maxBytes) {
|
||||
return null
|
||||
}
|
||||
const json = JSON.stringify(checkpoint)
|
||||
if (Buffer.byteLength(json, 'utf8') > maxBytes) {
|
||||
throw new Error('Terminal checkpoint size estimator mismatch')
|
||||
}
|
||||
return json
|
||||
const writer = new BoundedJsonWriter(maxBytes)
|
||||
appendJsonValue(writer, checkpoint, new Set())
|
||||
return writer.result()
|
||||
}
|
||||
|
||||
async function replaySnapshot(snapshot: TerminalSnapshot): Promise<HeadlessEmulator> {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
|
||||
const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoisted(() => ({
|
||||
copyFileMock: vi.fn(),
|
||||
handleMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
realpathMock: vi.fn(),
|
||||
renameMock: vi.fn()
|
||||
}))
|
||||
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: handleMock }
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
copyFile: copyFileMock,
|
||||
lstat: lstatMock,
|
||||
realpath: realpathMock,
|
||||
rename: renameMock
|
||||
}
|
||||
})
|
||||
|
||||
import { renameLocalPathSerializedByDestination } from './destination-serialized-local-rename'
|
||||
import { registerFilesystemMutationHandlers } from './ipc/filesystem-mutations'
|
||||
import { RuntimeFileCommands } from './runtime/orca-runtime-files'
|
||||
|
||||
const REPO_PATH = path.resolve('/workspace/repo')
|
||||
const store = {
|
||||
getRepos: () => [
|
||||
{ id: 'repo-1', path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 }
|
||||
],
|
||||
getSettings: () => ({ workspaceDir: path.resolve('/workspace') })
|
||||
}
|
||||
|
||||
function enoent(): Error {
|
||||
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
function mockStats(dev: number, ino: number) {
|
||||
return { dev, ino, isDirectory: () => false }
|
||||
}
|
||||
|
||||
function createRuntimeCommands(): RuntimeFileCommands {
|
||||
return new RuntimeFileCommands({
|
||||
requireStore: () => store,
|
||||
resolveRuntimeFileTarget: async () => ({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH }
|
||||
})
|
||||
} as never)
|
||||
}
|
||||
|
||||
function createGate(): { promise: Promise<void>; release: () => void } {
|
||||
let release!: () => void
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return { promise, release }
|
||||
}
|
||||
|
||||
async function reachNextMacrotask(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('renameLocalPathSerializedByDestination', () => {
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
copyFileMock.mockReset()
|
||||
handleMock.mockReset()
|
||||
lstatMock.mockReset()
|
||||
realpathMock.mockReset()
|
||||
renameMock.mockReset()
|
||||
handleMock.mockImplementation((channel: string, handler: never) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
lstatMock.mockRejectedValue(enoent())
|
||||
realpathMock.mockImplementation(async (filePath: string) => filePath)
|
||||
renameMock.mockResolvedValue(undefined)
|
||||
registerFilesystemMutationHandlers(store as never)
|
||||
})
|
||||
|
||||
it('serializes exact Unicode aliases across IPC and runtime entry points', async () => {
|
||||
const ipcSource = path.join(REPO_PATH, 'ipc-source.md')
|
||||
const runtimeSource = path.join(REPO_PATH, 'runtime-source.md')
|
||||
const sharpSDestination = path.join(REPO_PATH, 'Straße.md')
|
||||
const expandedDestination = path.join(REPO_PATH, 'STRASSE.md')
|
||||
const firstRenameGate = createGate()
|
||||
let destinationExists = false
|
||||
lstatMock.mockImplementation(async (filePath: string) => {
|
||||
if (
|
||||
destinationExists &&
|
||||
(filePath === sharpSDestination || filePath === expandedDestination)
|
||||
) {
|
||||
return mockStats(1, 30)
|
||||
}
|
||||
if (filePath === ipcSource) {
|
||||
return mockStats(1, 10)
|
||||
}
|
||||
if (filePath === runtimeSource) {
|
||||
return mockStats(1, 20)
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
renameMock.mockImplementation(async () => {
|
||||
if (renameMock.mock.calls.length === 1) {
|
||||
await firstRenameGate.promise
|
||||
}
|
||||
destinationExists = true
|
||||
})
|
||||
|
||||
const ipcRename = handlers.get('fs:rename')!(null, {
|
||||
oldPath: ipcSource,
|
||||
newPath: sharpSDestination
|
||||
})
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
|
||||
const runtimeRename = createRuntimeCommands().renameFileExplorerPath(
|
||||
'id:wt-1',
|
||||
'runtime-source.md',
|
||||
'STRASSE.md',
|
||||
undefined,
|
||||
undefined,
|
||||
'local'
|
||||
)
|
||||
await reachNextMacrotask()
|
||||
const callsWhileFirstBlocked = renameMock.mock.calls.length
|
||||
firstRenameGate.release()
|
||||
const results = await Promise.allSettled([ipcRename, runtimeRename])
|
||||
|
||||
expect(callsWhileFirstBlocked).toBe(1)
|
||||
expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected'])
|
||||
expect(renameMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases an alias queue after the leading rename errors', async () => {
|
||||
const firstRenameGate = createGate()
|
||||
renameMock.mockImplementation(async () => {
|
||||
if (renameMock.mock.calls.length === 1) {
|
||||
await firstRenameGate.promise
|
||||
throw new Error('EACCES')
|
||||
}
|
||||
})
|
||||
const destination = path.join(REPO_PATH, 'destination.md')
|
||||
|
||||
const firstRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'first.md'),
|
||||
destination
|
||||
)
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
|
||||
const secondRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'second.md'),
|
||||
destination
|
||||
)
|
||||
await reachNextMacrotask()
|
||||
expect(renameMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstRenameGate.release()
|
||||
await expect(firstRename).rejects.toThrow('EACCES')
|
||||
await expect(secondRename).resolves.toBeUndefined()
|
||||
expect(renameMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not serialize destinations in unrelated parent directories', async () => {
|
||||
const firstRenameGate = createGate()
|
||||
renameMock.mockImplementation(async () => {
|
||||
if (renameMock.mock.calls.length === 1) {
|
||||
await firstRenameGate.promise
|
||||
}
|
||||
})
|
||||
|
||||
const firstRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'first', 'source.md'),
|
||||
path.join(REPO_PATH, 'first', 'alpha.md')
|
||||
)
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
|
||||
const secondRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'second', 'source.md'),
|
||||
path.join(REPO_PATH, 'second', 'beta.md')
|
||||
)
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(2))
|
||||
|
||||
firstRenameGate.release()
|
||||
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
it('serializes symlink aliases of one destination parent', async () => {
|
||||
const firstRenameGate = createGate()
|
||||
renameMock.mockImplementation(async () => {
|
||||
if (renameMock.mock.calls.length === 1) {
|
||||
await firstRenameGate.promise
|
||||
}
|
||||
})
|
||||
const firstParent = path.join(REPO_PATH, 'parent-link-a')
|
||||
const secondParent = path.join(REPO_PATH, 'parent-link-b')
|
||||
const canonicalParent = path.join(REPO_PATH, 'canonical-parent')
|
||||
realpathMock.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === firstParent || filePath === secondParent) {
|
||||
return canonicalParent
|
||||
}
|
||||
return filePath
|
||||
})
|
||||
|
||||
const firstRename = renameLocalPathSerializedByDestination(
|
||||
path.join(firstParent, 'source.md'),
|
||||
path.join(firstParent, 'alpha.md')
|
||||
)
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
|
||||
const secondRename = renameLocalPathSerializedByDestination(
|
||||
path.join(secondParent, 'source.md'),
|
||||
path.join(secondParent, 'beta.md')
|
||||
)
|
||||
await reachNextMacrotask()
|
||||
expect(renameMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstRenameGate.release()
|
||||
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
|
||||
expect(renameMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('scopes conservative serialization to one destination parent', async () => {
|
||||
const firstRenameGate = createGate()
|
||||
renameMock.mockImplementation(async () => {
|
||||
if (renameMock.mock.calls.length === 1) {
|
||||
await firstRenameGate.promise
|
||||
}
|
||||
})
|
||||
|
||||
const firstRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'first.md'),
|
||||
path.join(REPO_PATH, 'dotless-ı.md')
|
||||
)
|
||||
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
|
||||
const secondRename = renameLocalPathSerializedByDestination(
|
||||
path.join(REPO_PATH, 'second.md'),
|
||||
path.join(REPO_PATH, 'dotless-I.md')
|
||||
)
|
||||
await reachNextMacrotask()
|
||||
expect(renameMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstRenameGate.release()
|
||||
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
|
||||
expect(renameMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { realpath, rename } from 'node:fs/promises'
|
||||
import { dirname, normalize } from 'node:path'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
|
||||
|
||||
// Why: parent scope covers native Unicode aliases without guessing each filesystem's collation.
|
||||
const pendingRenamesByParent = new Map<string, Promise<void>>()
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
async function destinationParentKey(filePath: string): Promise<string> {
|
||||
const parentPath = dirname(filePath)
|
||||
try {
|
||||
return normalize(await realpath(parentPath))
|
||||
} catch (error) {
|
||||
// A missing parent will fail the rename; retain its path so that failure
|
||||
// does not prevent unrelated destinations from progressing.
|
||||
if (isENOENT(error)) {
|
||||
return normalize(parentPath)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameLocalPathSerializedByDestination(
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): Promise<void> {
|
||||
const key = await destinationParentKey(newPath)
|
||||
const previous = pendingRenamesByParent.get(key) ?? Promise.resolve()
|
||||
let release!: () => void
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
pendingRenamesByParent.set(key, current)
|
||||
|
||||
await previous
|
||||
try {
|
||||
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
|
||||
await rename(oldPath, newPath)
|
||||
} finally {
|
||||
release()
|
||||
if (pendingRenamesByParent.get(key) === current) {
|
||||
pendingRenamesByParent.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,36 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
|
||||
})
|
||||
|
||||
it('serializes concurrent local renames targeting the same destination', async () => {
|
||||
const firstPath = path.resolve('/workspace/repo/first.ts')
|
||||
const secondPath = path.resolve('/workspace/repo/second.ts')
|
||||
const destinationPath = path.resolve('/workspace/repo/destination.ts')
|
||||
let destinationExists = false
|
||||
lstatMock.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === destinationPath && destinationExists) {
|
||||
return mockStats(1, 30)
|
||||
}
|
||||
if (filePath === firstPath) {
|
||||
return mockStats(1, 10)
|
||||
}
|
||||
if (filePath === secondPath) {
|
||||
return mockStats(1, 20)
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
renameMock.mockImplementation(async () => {
|
||||
destinationExists = true
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
handlers.get('fs:rename')!(null, { oldPath: firstPath, newPath: destinationPath }),
|
||||
handlers.get('fs:rename')!(null, { oldPath: secondPath, newPath: destinationPath })
|
||||
])
|
||||
|
||||
expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected'])
|
||||
expect(renameMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects rename when destination already exists as a true collision', async () => {
|
||||
const oldPath = path.resolve('/workspace/repo/old.ts')
|
||||
const resolvedNewPath = path.resolve('/workspace/repo/new.ts')
|
||||
@@ -197,12 +227,30 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
it('allows case-only rename when destination is the same entry in the same parent', async () => {
|
||||
const oldPath = path.resolve('/workspace/repo/README.md')
|
||||
const newPath = path.resolve('/workspace/repo/readme.md')
|
||||
const canonicalPath = path.resolve('/workspace/repo/README.md')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === oldPath || p === newPath) {
|
||||
return mockStats(2, 20)
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
mockRealpath({ [oldPath]: canonicalPath, [newPath]: canonicalPath })
|
||||
|
||||
await handlers.get('fs:rename')!(null, { oldPath, newPath })
|
||||
|
||||
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
|
||||
})
|
||||
|
||||
it('allows a native Unicode alias rename for the same directory entry', async () => {
|
||||
const oldPath = path.resolve('/workspace/repo/Straße.md')
|
||||
const newPath = path.resolve('/workspace/repo/STRASSE.md')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === oldPath || p === newPath) {
|
||||
return mockStats(2, 21)
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
mockRealpath({ [oldPath]: oldPath, [newPath]: oldPath })
|
||||
|
||||
await handlers.get('fs:rename')!(null, { oldPath, newPath })
|
||||
|
||||
@@ -226,6 +274,25 @@ describe('registerFilesystemMutationHandlers', () => {
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when same-entry realpath encounters a permission error', async () => {
|
||||
const oldPath = path.resolve('/workspace/repo/README.md')
|
||||
const newPath = path.resolve('/workspace/repo/readme.md')
|
||||
lstatMock.mockImplementation(async (p: string) => {
|
||||
if (p === oldPath || p === newPath) {
|
||||
return mockStats(3, 30)
|
||||
}
|
||||
throw enoent()
|
||||
})
|
||||
realpathMock.mockRejectedValue(
|
||||
Object.assign(new Error('permission denied'), { code: 'EACCES' })
|
||||
)
|
||||
|
||||
await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toMatchObject({
|
||||
code: 'EACCES'
|
||||
})
|
||||
expect(renameMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects cross-parent case-only rename collisions even when dev and ino match', async () => {
|
||||
const oldPath = path.resolve('/workspace/repo/src/README.md')
|
||||
const newPath = path.resolve('/workspace/repo/docs/readme.md')
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
unlink,
|
||||
writeFile
|
||||
@@ -21,9 +20,9 @@ import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesy
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution'
|
||||
import { importExternalPathsSsh } from './filesystem-import-ssh'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
|
||||
import type { SshMutationExpectation } from '../../shared/ssh-types'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename'
|
||||
|
||||
/**
|
||||
* Re-throw filesystem errors with user-friendly messages.
|
||||
@@ -146,8 +145,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
|
||||
// accidentally write into a symlinked destination name.
|
||||
const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true })
|
||||
const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true })
|
||||
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
|
||||
await rename(oldPath, newPath)
|
||||
await renameLocalPathSerializedByDestination(oldPath, newPath)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -151,7 +151,6 @@ describe('registerMobileHandlers', () => {
|
||||
pairingUrl: 'orca://pair#mobile',
|
||||
endpoint: 'ws://100.102.47.57:6768',
|
||||
deviceId: 'mobile-1',
|
||||
// The encoded mode passes through so the UI can flag a degraded mint.
|
||||
connectionMode: 'automatic'
|
||||
})
|
||||
|
||||
@@ -163,6 +162,32 @@ describe('registerMobileHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards structured Relay mint failures to the renderer', async () => {
|
||||
networkInterfacesMock.mockReturnValue({
|
||||
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }]
|
||||
})
|
||||
const relayFailure = {
|
||||
code: 'relay_mint_failed',
|
||||
stage: 'create_pairing_relay',
|
||||
message: 'Relay pairing invite request failed'
|
||||
}
|
||||
const createMobilePairingOffer = vi.fn().mockResolvedValue({
|
||||
available: false,
|
||||
reason: 'relay_mint_failed',
|
||||
guidance: 'Use LAN or retry Relay.',
|
||||
relayFailure
|
||||
})
|
||||
|
||||
registerMobileHandlers({ createMobilePairingOffer } as never)
|
||||
|
||||
await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toEqual({
|
||||
available: false,
|
||||
reason: 'relay_mint_failed',
|
||||
guidance: 'Use LAN or retry Relay.',
|
||||
relayFailure
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards an explicit local-only pairing choice', async () => {
|
||||
networkInterfacesMock.mockReturnValue({
|
||||
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }]
|
||||
|
||||
+14
-5
@@ -126,7 +126,12 @@ export function registerMobileHandlers(
|
||||
// ZeroTier) where the default LAN IP isn't reachable from the phone.
|
||||
const ip = args?.address ?? getDefaultPairingAddress()
|
||||
if (!ip) {
|
||||
return { available: false as const }
|
||||
return {
|
||||
available: false as const,
|
||||
reason: 'invalid_advertised_endpoint',
|
||||
guidance:
|
||||
'No reachable network address is available for pairing. Connect to Wi‑Fi or Tailscale, or pick an address manually.'
|
||||
}
|
||||
}
|
||||
|
||||
// Why: coalesce repeated QR regenerations onto a single never-scanned
|
||||
@@ -143,7 +148,14 @@ export function registerMobileHandlers(
|
||||
name: `Mobile ${new Date().toLocaleDateString()}`
|
||||
})
|
||||
if (!offer.available) {
|
||||
return { available: false as const }
|
||||
// Why: surface Relay mint failures (and other pairing unavailability)
|
||||
// so the UI can refuse a silent LAN QR under the Relay label.
|
||||
return {
|
||||
available: false as const,
|
||||
reason: offer.reason,
|
||||
guidance: offer.guidance,
|
||||
...(offer.relayFailure ? { relayFailure: offer.relayFailure } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const qr = await (dependencies.encodePairingQr ?? encodeMobilePairingQr)(offer.pairingUrl)
|
||||
@@ -155,9 +167,6 @@ export function registerMobileHandlers(
|
||||
pairingUrl: offer.pairingUrl,
|
||||
endpoint: offer.endpoint,
|
||||
deviceId: offer.deviceId,
|
||||
// Why: an automatic request can degrade to a local-only offer when
|
||||
// Relay provisioning fails; the UI needs the encoded mode to avoid
|
||||
// labeling a LAN-only code as Relay.
|
||||
connectionMode: offer.connectionMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification'
|
||||
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
|
||||
import {
|
||||
callRuntimeEnvironment,
|
||||
@@ -64,6 +65,16 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
|
||||
return { environment: redactRuntimeEnvironment(environment) }
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:verifyAndAddFromPairingCode',
|
||||
async (_event, args: { name: string; pairingCode: string; allowLoopback?: boolean }) => {
|
||||
const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args)
|
||||
if (result.ok) {
|
||||
manuallyDisconnectedEnvironmentIds.delete(result.environment.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) =>
|
||||
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:list',
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:verifyAndAddFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
'runtimeEnvironments:connect',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
'runtimeEnvironments:subscribe',
|
||||
'runtimeEnvironments:unsubscribe'
|
||||
] as const
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
RuntimeEnvironmentStoreError
|
||||
} from '../../shared/runtime-environment-store'
|
||||
import { parseHostAccessLink } from '../../shared/remote-pairing-address'
|
||||
import {
|
||||
verifyRemotePairingRuntimeStatus,
|
||||
type VerifyAndAddRuntimeEnvironmentResult
|
||||
} from '../../shared/remote-pairing-verification'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
|
||||
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
|
||||
type VerifyAndAddRuntimeEnvironmentArgs = {
|
||||
name: string
|
||||
pairingCode: string
|
||||
allowLoopback?: boolean
|
||||
}
|
||||
|
||||
export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
|
||||
userDataPath: string,
|
||||
args: VerifyAndAddRuntimeEnvironmentArgs
|
||||
): Promise<VerifyAndAddRuntimeEnvironmentResult> {
|
||||
const parsed = parseHostAccessLink(args.pairingCode)
|
||||
if (!parsed.ok) {
|
||||
return { ok: false, kind: 'access-link-invalid', message: parsed.message }
|
||||
}
|
||||
if (parsed.value.endpointKind === 'loopback' && !args.allowLoopback) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'host-unreachable',
|
||||
message: 'This access link points back to this device.'
|
||||
}
|
||||
}
|
||||
|
||||
let runtimeStatus: RuntimeStatus
|
||||
try {
|
||||
const response = await sendRemoteRuntimeRequest<RuntimeStatus>(
|
||||
parsed.value.pairing,
|
||||
'status.get',
|
||||
undefined,
|
||||
15_000
|
||||
)
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'connection-interrupted',
|
||||
message: response.error.message
|
||||
}
|
||||
}
|
||||
const statusVerification = verifyRemotePairingRuntimeStatus(response.result)
|
||||
if (!statusVerification.ok) {
|
||||
return statusVerification
|
||||
}
|
||||
runtimeStatus = statusVerification.runtimeStatus
|
||||
} catch (error) {
|
||||
return classifyPairingVerificationError(error, parsed.value.displayEndpoint)
|
||||
}
|
||||
|
||||
const usesSshTunnel = parsed.value.endpointKind === 'loopback' && args.allowLoopback === true
|
||||
let environment: ReturnType<typeof addEnvironmentFromPairingCode>
|
||||
try {
|
||||
environment = addEnvironmentFromPairingCode(userDataPath, {
|
||||
...args,
|
||||
...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'environment-save-failed',
|
||||
message:
|
||||
error instanceof RuntimeEnvironmentStoreError && error.code === 'invalid_argument'
|
||||
? error.message
|
||||
: 'Orca verified the host but could not save it. Check local settings storage and try again.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
environment: redactRuntimeEnvironment(environment),
|
||||
runtimeStatus
|
||||
}
|
||||
}
|
||||
|
||||
function classifyPairingVerificationError(
|
||||
error: unknown,
|
||||
endpoint: string
|
||||
): VerifyAndAddRuntimeEnvironmentResult {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
if (error.code === 'invalid_argument') {
|
||||
return invalidAccessLinkResult()
|
||||
}
|
||||
if (error.code === 'unauthorized' || error.pairingStage === 'access-grant') {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'access-link-invalid',
|
||||
message: 'This access link is no longer valid. Generate a new link on the other host.'
|
||||
}
|
||||
}
|
||||
if (error.pairingStage === 'host-identity') {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'host-identity-mismatch',
|
||||
message: `Orca reached ${endpoint}, but that host does not match this access link.`
|
||||
}
|
||||
}
|
||||
if (error.pairingStage === 'runtime') {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'connection-interrupted',
|
||||
message: `The connection to ${endpoint} was interrupted during verification.`
|
||||
}
|
||||
}
|
||||
if (error.pairingStage === 'connect') {
|
||||
return unreachableHostResult(endpoint)
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message.startsWith('Invalid public key')
|
||||
? invalidAccessLinkResult()
|
||||
: { ok: false, kind: 'connection-interrupted', message: error.message }
|
||||
}
|
||||
return unreachableHostResult(endpoint)
|
||||
}
|
||||
|
||||
function invalidAccessLinkResult(): VerifyAndAddRuntimeEnvironmentResult {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'access-link-invalid',
|
||||
message: 'This access link contains invalid connection details.'
|
||||
}
|
||||
}
|
||||
|
||||
function unreachableHostResult(endpoint: string): VerifyAndAddRuntimeEnvironmentResult {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'host-unreachable',
|
||||
message: `Cannot reach Orca at ${endpoint}. Confirm the other host is running and reachable.`
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { encodePairingOffer } from '../../shared/pairing'
|
||||
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY
|
||||
} from '../../shared/protocol-version'
|
||||
import * as environmentStore from '../../shared/runtime-environment-store'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
@@ -79,6 +83,18 @@ function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeStatus(): Record<string, unknown> {
|
||||
return {
|
||||
runtimeId: 'runtime-a',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
protocolVersion: 999_999
|
||||
}
|
||||
}
|
||||
|
||||
function handler<TArgs, TResult>(
|
||||
channel: string
|
||||
): (_event: unknown, args: TArgs) => TResult | Promise<TResult> {
|
||||
@@ -132,6 +148,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
expect(handleMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'runtimeEnvironments:list',
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:verifyAndAddFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
@@ -153,6 +170,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'runtimeEnvironments:list',
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:verifyAndAddFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
@@ -211,6 +229,173 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
expect(await list(null, undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('blocks loopback before verification unless an SSH tunnel is declared', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string; allowLoopback?: boolean },
|
||||
{ ok: boolean; kind?: string }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
await expect(
|
||||
verifyAndAdd(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
kind: 'host-unreachable'
|
||||
})
|
||||
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
|
||||
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('verifies identity, access, status, and compatibility before saving', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: runtimeStatus(),
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string; allowLoopback?: boolean },
|
||||
{ ok: boolean; environment?: { name: string; connectionDependency?: string } }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
const result = await verifyAndAdd(null, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode(),
|
||||
allowLoopback: true
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
environment: { name: 'desk', connectionDependency: 'ssh-tunnel' }
|
||||
})
|
||||
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
|
||||
'status.get',
|
||||
undefined,
|
||||
15_000
|
||||
)
|
||||
expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not mark non-loopback hosts as SSH-tunnel dependent', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: runtimeStatus(),
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string; allowLoopback?: boolean },
|
||||
{ ok: boolean; environment?: { connectionDependency?: string } }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
const result = await verifyAndAdd(null, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode('ws://100.76.32.125:6768'),
|
||||
allowLoopback: true
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
expect(result.environment).not.toHaveProperty('connectionDependency')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ protocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 }, 'protocol-incompatible'],
|
||||
[{ protocolVersion: 999_999, deviceScope: 'mobile' }, 'access-link-invalid'],
|
||||
[null, 'connection-interrupted']
|
||||
])('does not save a host with rejected status %o', async (status, expectedKind) => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: status,
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ ok: boolean; kind?: string }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
await expect(
|
||||
verifyAndAdd(null, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode('ws://100.76.32.125:6768')
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, kind: expectedKind })
|
||||
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
new RemoteRuntimeClientError('remote_runtime_unavailable', 'closed', {
|
||||
pairingStage: 'host-identity',
|
||||
closeCode: 4001
|
||||
}),
|
||||
'host-identity-mismatch'
|
||||
],
|
||||
[
|
||||
new RemoteRuntimeClientError('unauthorized', 'rejected', {
|
||||
pairingStage: 'access-grant'
|
||||
}),
|
||||
'access-link-invalid'
|
||||
],
|
||||
[
|
||||
new RemoteRuntimeClientError('remote_runtime_unavailable', 'offline', {
|
||||
pairingStage: 'connect'
|
||||
}),
|
||||
'host-unreachable'
|
||||
],
|
||||
[new Error('Invalid public key: expected 32 bytes, got 3'), 'access-link-invalid']
|
||||
] as const)('returns a structured pairing failure for %s', async (error, expectedKind) => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockRejectedValue(error)
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ ok: boolean; kind?: string; message?: string }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
const result = await verifyAndAdd(null, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode('ws://100.76.32.125:6768')
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, kind: expectedKind })
|
||||
expect(result.message).not.toContain('4001')
|
||||
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns a structured failure when a verified host cannot be persisted', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
environmentStore.addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode('ws://100.76.32.125:6768')
|
||||
})
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: runtimeStatus(),
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
const verifyAndAdd = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ ok: boolean; kind?: string; message?: string }
|
||||
>('runtimeEnvironments:verifyAndAddFromPairingCode')
|
||||
|
||||
await expect(
|
||||
verifyAndAdd(null, {
|
||||
name: 'desk',
|
||||
pairingCode: pairingCode('ws://100.76.32.125:6768')
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
kind: 'environment-save-failed',
|
||||
message: 'A server named "desk" already exists.'
|
||||
})
|
||||
expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('requires an explicit Advanced selection before removing the Active Server', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const add = handler<
|
||||
|
||||
@@ -19,19 +19,7 @@ import {
|
||||
resetSharedControlSupport,
|
||||
subscribeRuntimeEnvironment
|
||||
} from './runtime-environment-transport-routing'
|
||||
|
||||
const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:list',
|
||||
'runtimeEnvironments:addFromPairingCode',
|
||||
'runtimeEnvironments:resolve',
|
||||
'runtimeEnvironments:remove',
|
||||
'runtimeEnvironments:disconnect',
|
||||
'runtimeEnvironments:connect',
|
||||
'runtimeEnvironments:getStatus',
|
||||
'runtimeEnvironments:call',
|
||||
'runtimeEnvironments:subscribe',
|
||||
'runtimeEnvironments:unsubscribe'
|
||||
] as const
|
||||
import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels'
|
||||
|
||||
type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & {
|
||||
environmentId: string
|
||||
|
||||
@@ -102,13 +102,15 @@ export class DeviceRegistry {
|
||||
}
|
||||
|
||||
removeDevice(deviceId: string): boolean {
|
||||
const before = this.devices.length
|
||||
this.devices = this.devices.filter((d) => d.deviceId !== deviceId)
|
||||
if (this.devices.length < before) {
|
||||
this.save()
|
||||
return true
|
||||
const nextDevices = this.devices.filter((d) => d.deviceId !== deviceId)
|
||||
if (nextDevices.length === this.devices.length) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
// Why: persist before memory swap so a failed write does not drop a device
|
||||
// only in-process while disk still lists it (and vice versa on reload).
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
return true
|
||||
}
|
||||
|
||||
getDevice(deviceId: string): DeviceEntry | null {
|
||||
@@ -120,22 +122,30 @@ export class DeviceRegistry {
|
||||
}
|
||||
|
||||
setRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean {
|
||||
const device = this.devices.find((candidate) => candidate.deviceId === deviceId)
|
||||
if (!device || binding.relayDeviceId !== deviceId) {
|
||||
const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId)
|
||||
if (index < 0 || binding.relayDeviceId !== deviceId) {
|
||||
return false
|
||||
}
|
||||
device.relayBinding = binding
|
||||
this.save()
|
||||
const nextDevices = this.devices.map((device, candidateIndex) =>
|
||||
candidateIndex === index ? { ...device, relayBinding: binding } : device
|
||||
)
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
return true
|
||||
}
|
||||
|
||||
setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean {
|
||||
const device = this.devices.find((candidate) => candidate.deviceId === deviceId)
|
||||
if (!device || device.scope !== 'mobile') {
|
||||
const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId)
|
||||
if (index < 0 || this.devices[index]?.scope !== 'mobile') {
|
||||
return false
|
||||
}
|
||||
device.mobilePairingConnectionMode = mode
|
||||
this.save()
|
||||
// Why: persist before swapping memory so a failed write does not leave a
|
||||
// mode the UI/runtime believe was stored.
|
||||
const nextDevices = this.devices.map((device, candidateIndex) =>
|
||||
candidateIndex === index ? { ...device, mobilePairingConnectionMode: mode } : device
|
||||
)
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -158,11 +168,18 @@ export class DeviceRegistry {
|
||||
}
|
||||
|
||||
updateLastSeen(deviceId: string): void {
|
||||
const device = this.devices.find((d) => d.deviceId === deviceId)
|
||||
if (device) {
|
||||
device.lastSeenAt = Date.now()
|
||||
this.save()
|
||||
const index = this.devices.findIndex((d) => d.deviceId === deviceId)
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
// Why: persist before memory swap so a failed write cannot leave a scanned
|
||||
// device looking never-scanned on disk, where rotation would drop it.
|
||||
const seenAt = Date.now()
|
||||
const nextDevices = this.devices.map((device, candidateIndex) =>
|
||||
candidateIndex === index ? { ...device, lastSeenAt: seenAt } : device
|
||||
)
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
@@ -187,7 +204,7 @@ export class DeviceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
private save(devices: DeviceEntry[] = this.devices): void {
|
||||
private save(devices: DeviceEntry[]): void {
|
||||
writeSecureJsonFile(this.registryPath, devices)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation'
|
||||
|
||||
function createProvider(sessionIds: string[]): IPtyProvider {
|
||||
return {
|
||||
listProcesses: vi.fn(async () =>
|
||||
sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' }))
|
||||
),
|
||||
shutdown: vi.fn(async () => {})
|
||||
} as unknown as IPtyProvider
|
||||
}
|
||||
|
||||
function createRuntime(): OrcaRuntimeService {
|
||||
return {
|
||||
stopTerminalsForWorktree: vi.fn(async () => ({ stopped: 0 }))
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
const localRepo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1
|
||||
}
|
||||
|
||||
describe('stopMissingWorktreeTerminals', () => {
|
||||
it('stops only locally owned worktrees absent from the authoritative scan', async () => {
|
||||
const deletedId = 'repo-1::/workspace/deleted'
|
||||
const survivingId = 'repo-1::/workspace/surviving'
|
||||
const provider = createProvider([
|
||||
`${deletedId}@@deleted-session`,
|
||||
`${survivingId}@@surviving-session`
|
||||
])
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(
|
||||
localRepo,
|
||||
[deletedId, survivingId, 'repo-2::/workspace/other'],
|
||||
[survivingId],
|
||||
{
|
||||
runtime,
|
||||
getLocalProvider: () => provider,
|
||||
getSshProvider: () => undefined
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(provider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@deleted-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(provider.shutdown).not.toHaveBeenCalledWith(
|
||||
`${survivingId}@@surviving-session`,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the owning SSH provider without consulting the local provider', async () => {
|
||||
const deletedId = 'repo-1::/workspace/deleted'
|
||||
const localProvider = createProvider([`${deletedId}@@local-session`])
|
||||
const sshProvider = createProvider([`${deletedId}@@ssh-session`])
|
||||
const getSshProvider = vi.fn(() => sshProvider)
|
||||
|
||||
await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, [deletedId], [], {
|
||||
runtime: createRuntime(),
|
||||
getLocalProvider: () => localProvider,
|
||||
getSshProvider
|
||||
})
|
||||
|
||||
expect(getSshProvider).toHaveBeenCalledWith('ssh-1')
|
||||
expect(sshProvider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@ssh-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(localProvider.listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still stops graph-visible sessions when the owning provider is unavailable', async () => {
|
||||
const deletedId = 'repo-1::/workspace/deleted'
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(
|
||||
{ ...localRepo, connectionId: 'ssh-1' },
|
||||
[deletedId],
|
||||
[],
|
||||
{
|
||||
runtime,
|
||||
getLocalProvider: () => null,
|
||||
getSshProvider: () => undefined
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith(deletedId)
|
||||
})
|
||||
|
||||
// Why: an agent cleaning up workspaces deletes many at once. Enumerating the
|
||||
// host once per missing worktree is O(N) relay round-trips carrying O(N^2)
|
||||
// rows — on SSH that stalls teardown for minutes.
|
||||
it('enumerates the host once for the whole sweep', async () => {
|
||||
const ids = Array.from({ length: 25 }, (_, index) => `repo-1::/workspace/wt-${index}`)
|
||||
const provider = createProvider(ids.map((id) => `${id}@@session`))
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(
|
||||
{ ...localRepo, connectionId: 'ssh-1' },
|
||||
ids,
|
||||
[],
|
||||
{ runtime: createRuntime(), getLocalProvider: () => null, getSshProvider: () => provider }
|
||||
)
|
||||
|
||||
expect(result.stoppedWorktreeIds).toHaveLength(ids.length)
|
||||
expect(provider.listProcesses).toHaveBeenCalledTimes(1)
|
||||
expect(provider.shutdown).toHaveBeenCalledTimes(ids.length)
|
||||
})
|
||||
|
||||
// Why: real providers put listProcesses/shutdown on the prototype and use `this`;
|
||||
// batching them behind a wrapper must not break that binding.
|
||||
it('keeps provider method binding intact while batching', async () => {
|
||||
class PrototypeProvider {
|
||||
listCalls = 0
|
||||
shutdownCalls: string[] = []
|
||||
private readonly sessions: { id: string; cwd: string; title: string }[]
|
||||
constructor(sessionIds: string[]) {
|
||||
this.sessions = sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' }))
|
||||
}
|
||||
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
|
||||
this.listCalls += 1
|
||||
return this.sessions
|
||||
}
|
||||
async shutdown(sessionId: string): Promise<void> {
|
||||
this.shutdownCalls.push(sessionId)
|
||||
}
|
||||
}
|
||||
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b', 'repo-1::/workspace/c']
|
||||
const provider = new PrototypeProvider(ids.map((id) => `${id}@@session`))
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(
|
||||
{ ...localRepo, connectionId: 'ssh-1' },
|
||||
ids,
|
||||
[],
|
||||
{
|
||||
runtime: createRuntime(),
|
||||
getLocalProvider: () => null,
|
||||
getSshProvider: () => provider as unknown as IPtyProvider
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.stoppedWorktreeIds).toHaveLength(ids.length)
|
||||
expect(provider.listCalls).toBe(1)
|
||||
expect(provider.shutdownCalls).toHaveLength(ids.length)
|
||||
})
|
||||
|
||||
// Why: the batching must not leak past the calls it was built for. If the proxy
|
||||
// were the receiver, a provider whose own method called `this.listProcesses()`
|
||||
// would silently read this sweep's cached snapshot instead of the live host.
|
||||
it('does not serve the shared snapshot to provider-internal listProcesses', async () => {
|
||||
class SelfListingProvider {
|
||||
listCalls = 0
|
||||
constructor(private readonly sessions: { id: string; cwd: string; title: string }[]) {}
|
||||
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
|
||||
this.listCalls += 1
|
||||
return this.sessions
|
||||
}
|
||||
async shutdown(): Promise<void> {
|
||||
// A provider that re-reads live state as part of stopping.
|
||||
await this.listProcesses()
|
||||
}
|
||||
}
|
||||
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b']
|
||||
const provider = new SelfListingProvider(
|
||||
ids.map((id) => ({ id: `${id}@@session`, cwd: '/workspace', title: 'shell' }))
|
||||
)
|
||||
|
||||
await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], {
|
||||
runtime: createRuntime(),
|
||||
getLocalProvider: () => null,
|
||||
getSshProvider: () => provider as unknown as IPtyProvider
|
||||
})
|
||||
|
||||
// One shared sweep scan, plus each shutdown's own live re-read.
|
||||
expect(provider.listCalls).toBe(1 + ids.length)
|
||||
})
|
||||
|
||||
// Why: caching a rejected scan would let one transient failure suppress
|
||||
// teardown for every remaining worktree in the sweep.
|
||||
it('does not reuse a failed process scan', async () => {
|
||||
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b']
|
||||
const listProcesses = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('relay dropped'))
|
||||
.mockResolvedValue([{ id: `${ids[1]}@@session`, cwd: '/workspace', title: 'shell' }])
|
||||
const provider = { listProcesses, shutdown: vi.fn(async () => {}) } as unknown as IPtyProvider
|
||||
|
||||
await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], {
|
||||
runtime: createRuntime(),
|
||||
getLocalProvider: () => null,
|
||||
getSshProvider: () => provider
|
||||
})
|
||||
|
||||
expect(listProcesses.mock.calls.length).toBeGreaterThan(1)
|
||||
expect(provider.shutdown).toHaveBeenCalledWith(
|
||||
`${ids[1]}@@session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import { killAllProcessesForWorktree } from './worktree-teardown'
|
||||
|
||||
const MISSING_WORKTREE_TEARDOWN_CONCURRENCY = 4
|
||||
|
||||
// Why: every worktree in one sweep is already known-missing, so they can all be
|
||||
// served by a single point-in-time process list. Without this, an agent that
|
||||
// deletes N workspaces costs N full host enumerations — O(N) relay round-trips
|
||||
// carrying O(N^2) rows, which is minutes of stalled teardown on a remote host.
|
||||
//
|
||||
// Safe only because this sweep is best-effort. Do NOT reuse this wrapper on a
|
||||
// `requirePhysicalStop` path: that re-lists *after* shutdown to prove a PTY
|
||||
// exited, and a pre-shutdown snapshot would make the proof read stale rows.
|
||||
function withSharedProcessSnapshot(provider: IPtyProvider): IPtyProvider {
|
||||
let snapshot: Promise<Awaited<ReturnType<IPtyProvider['listProcesses']>>> | null = null
|
||||
return new Proxy(provider, {
|
||||
get(target, property) {
|
||||
if (property !== 'listProcesses') {
|
||||
// Why: bind other members to `target`, not the proxy. With the proxy as
|
||||
// receiver, a provider whose own method called `this.listProcesses()`
|
||||
// would silently read this sweep's cached snapshot instead of the live
|
||||
// host — the batching must not leak past the calls it was built for.
|
||||
const member: unknown = Reflect.get(target, property)
|
||||
return typeof member === 'function' ? member.bind(target) : member
|
||||
}
|
||||
return async (opts?: { deadlineMs?: number }) => {
|
||||
const pending = (snapshot ??= target.listProcesses(opts))
|
||||
try {
|
||||
return await pending
|
||||
} catch {
|
||||
// Why: a shared *rejection* would let one transient relay failure suppress
|
||||
// the provider sweep for every worktree in the batch — the exact leak this
|
||||
// reconciliation exists to prevent. Failures fall back to a per-caller scan,
|
||||
// restoring pre-batching isolation while success still costs one round-trip.
|
||||
if (snapshot === pending) {
|
||||
snapshot = null
|
||||
}
|
||||
return target.listProcesses(opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type MissingWorktreeTerminalReconciliationDeps = {
|
||||
runtime: OrcaRuntimeService
|
||||
getLocalProvider: () => IPtyProvider | null
|
||||
getSshProvider: (connectionId: string) => IPtyProvider | undefined
|
||||
onPtyStopped?: (ptyId: string) => void
|
||||
}
|
||||
|
||||
export async function stopMissingWorktreeTerminals(
|
||||
repo: Repo,
|
||||
knownWorktreeIds: readonly string[],
|
||||
detectedWorktreeIds: readonly string[],
|
||||
deps: MissingWorktreeTerminalReconciliationDeps
|
||||
): Promise<{ stoppedWorktreeIds: string[] }> {
|
||||
const detectedIds = new Set(detectedWorktreeIds)
|
||||
const missingIds = [
|
||||
...new Set(
|
||||
knownWorktreeIds.filter(
|
||||
(worktreeId) =>
|
||||
splitWorktreeId(worktreeId)?.repoId === repo.id && !detectedIds.has(worktreeId)
|
||||
)
|
||||
)
|
||||
]
|
||||
if (missingIds.length === 0) {
|
||||
return { stoppedWorktreeIds: [] }
|
||||
}
|
||||
|
||||
const ownedProvider = repo.connectionId
|
||||
? deps.getSshProvider(repo.connectionId)
|
||||
: deps.getLocalProvider()
|
||||
const provider = ownedProvider ? withSharedProcessSnapshot(ownedProvider) : ownedProvider
|
||||
if (!provider) {
|
||||
const stoppedWorktreeIds = (
|
||||
await mapWithConcurrency(
|
||||
missingIds,
|
||||
MISSING_WORKTREE_TEARDOWN_CONCURRENCY,
|
||||
async (worktreeId) => {
|
||||
try {
|
||||
await deps.runtime.stopTerminalsForWorktree(worktreeId)
|
||||
return worktreeId
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
).filter((worktreeId): worktreeId is string => worktreeId !== null)
|
||||
return { stoppedWorktreeIds }
|
||||
}
|
||||
|
||||
const stoppedWorktreeIds = (
|
||||
await mapWithConcurrency(
|
||||
missingIds,
|
||||
MISSING_WORKTREE_TEARDOWN_CONCURRENCY,
|
||||
async (worktreeId) => {
|
||||
try {
|
||||
await killAllProcessesForWorktree(worktreeId, {
|
||||
runtime: deps.runtime,
|
||||
localProvider: provider,
|
||||
onPtyStopped: deps.onPtyStopped,
|
||||
// Why: the shared process snapshot is only valid while nothing needs a
|
||||
// post-shutdown re-list, so this sweep stays explicitly best-effort.
|
||||
requirePhysicalStop: false,
|
||||
...(repo.connectionId ? { includeLocalRegistry: false } : {})
|
||||
})
|
||||
return worktreeId
|
||||
} catch (error) {
|
||||
console.warn(`[worktree-teardown] Failed to stop missing workspace ${worktreeId}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
).filter((worktreeId): worktreeId is string => worktreeId !== null)
|
||||
|
||||
return { stoppedWorktreeIds }
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { encodePairingOffer } from '../../shared/pairing'
|
||||
import type { PairingOffer } from '../../shared/mobile-relay-pairing-offer'
|
||||
import { encodeMobilePairingQr } from './mobile-pairing-qr'
|
||||
|
||||
// Keep every capacity probe in the same encoded payload family.
|
||||
const FIXED_INVITE_EXPIRES_AT = Date.now() + 5 * 60_000
|
||||
|
||||
function pairingUrl(endpointLength: number, relay: boolean): string {
|
||||
const prefix = 'wss://pair.example/'
|
||||
const offer: PairingOffer = {
|
||||
@@ -20,7 +23,7 @@ function pairingUrl(endpointLength: number, relay: boolean): string {
|
||||
assignmentEpoch: 1,
|
||||
relayHostId: 'a'.repeat(16),
|
||||
inviteToken: 'b'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
inviteExpiresAt: FIXED_INVITE_EXPIRES_AT,
|
||||
e2eeFraming: 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,6 @@ import {
|
||||
isWatcherProcessFailure,
|
||||
WatcherProcessFailure
|
||||
} from '../ipc/parcel-watcher-process-failure'
|
||||
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
|
||||
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
|
||||
import {
|
||||
rankRuntimeMobileFilePaths,
|
||||
@@ -89,6 +88,7 @@ import {
|
||||
import { beginWatcherInstall } from '../ipc/watcher-removal-gate'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename'
|
||||
|
||||
const MOBILE_FILE_LIST_LIMIT = 5000
|
||||
const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000
|
||||
@@ -1728,8 +1728,7 @@ export class RuntimeFileCommands {
|
||||
const store = this.host.requireStore()
|
||||
const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true })
|
||||
const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true })
|
||||
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
|
||||
await rename(oldPath, newPath)
|
||||
await renameLocalPathSerializedByDestination(oldPath, newPath)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import {
|
||||
HEADLESS_RUNTIME_WINDOW_ID,
|
||||
type RuntimeMobileSessionTabsResult,
|
||||
type RuntimeSyncWindowGraph,
|
||||
type RuntimeTerminalCreate
|
||||
} from '../../shared/runtime-types'
|
||||
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
|
||||
@@ -2514,6 +2515,130 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a launch-draft resolution to the handle-owning local and remote renderers', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const nativeChatLaunchDraftResolved = vi.fn()
|
||||
const events: RuntimeClientEvent[] = []
|
||||
runtime.setNotifier({ nativeChatLaunchDraftResolved } as never)
|
||||
runtime.onClientEvent((event) => events.push(event))
|
||||
runtime.attachWindow(1)
|
||||
const graph: RuntimeSyncWindowGraph = {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: 'repo-1::/tmp/worktree-a',
|
||||
publicationEpoch: 'launch-draft-epoch',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'tab-1::pane:1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::pane:1',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
title: 'Claude',
|
||||
launchDraft: 'seed',
|
||||
launchDraftCreatedAt: 7,
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
runtime.syncWindowGraph(1, graph)
|
||||
const listed = await runtime.listMobileSessionTabs('branch:feature/foo')
|
||||
const mobileTab = listed.tabs.find((tab) => tab.type === 'terminal')
|
||||
if (!mobileTab?.terminal) {
|
||||
throw new Error('expected mobile terminal handle')
|
||||
}
|
||||
expect(mobileTab).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 7 })
|
||||
|
||||
runtime.notifyNativeChatLaunchDraftResolved(mobileTab.terminal, {
|
||||
text: 'seed',
|
||||
createdAt: 7
|
||||
})
|
||||
|
||||
expect(nativeChatLaunchDraftResolved).toHaveBeenCalledWith('tab-1', {
|
||||
text: 'seed',
|
||||
createdAt: 7
|
||||
})
|
||||
expect(events).toContainEqual({
|
||||
type: 'nativeChatLaunchDraftResolved',
|
||||
tabId: 'tab-1',
|
||||
text: 'seed',
|
||||
createdAt: 7
|
||||
})
|
||||
expect(runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot()).toContainEqual({
|
||||
type: 'nativeChatLaunchDraftResolved',
|
||||
tabId: 'tab-1',
|
||||
text: 'seed',
|
||||
createdAt: 7
|
||||
})
|
||||
const retired = (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find(
|
||||
(tab) => tab.type === 'terminal'
|
||||
)
|
||||
expect(retired).not.toHaveProperty('launchDraft')
|
||||
expect(retired).not.toHaveProperty('launchDraftCreatedAt')
|
||||
|
||||
runtime.markRendererReloading(1)
|
||||
const replay = runtime.syncWindowGraph(1, {
|
||||
...graph,
|
||||
mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({
|
||||
...snapshot,
|
||||
publicationEpoch: 'launch-draft-reload',
|
||||
snapshotVersion: 2
|
||||
}))
|
||||
})
|
||||
expect(replay.nativeChatLaunchDraftResolutions).toEqual([
|
||||
{ tabId: 'tab-1', text: 'seed', createdAt: 7 }
|
||||
])
|
||||
expect(
|
||||
(await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find(
|
||||
(tab) => tab.type === 'terminal'
|
||||
)
|
||||
).not.toHaveProperty('launchDraft')
|
||||
|
||||
const reconciled = runtime.syncWindowGraph(1, {
|
||||
...graph,
|
||||
mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({
|
||||
...snapshot,
|
||||
publicationEpoch: 'launch-draft-reload',
|
||||
snapshotVersion: 3,
|
||||
tabs: snapshot.tabs.map((tab) => {
|
||||
if (tab.type !== 'terminal') {
|
||||
return tab
|
||||
}
|
||||
return { ...tab, launchDraftCreatedAt: 8 }
|
||||
})
|
||||
}))
|
||||
})
|
||||
expect(reconciled.nativeChatLaunchDraftResolutions).toBeUndefined()
|
||||
expect(
|
||||
(await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find(
|
||||
(tab) => tab.type === 'terminal'
|
||||
)
|
||||
).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 8 })
|
||||
})
|
||||
|
||||
it('surfaces stale terminal handles for stranded panes and recovers after same-pane wake', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const tabId = 'tab-1'
|
||||
@@ -34409,6 +34534,191 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(removeWorktreeLineage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revalidates missing worktrees before stopping their local provider sessions', async () => {
|
||||
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
|
||||
const survivingId = `${TEST_REPO_ID}::/tmp/surviving`
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{ id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' },
|
||||
{ id: `${survivingId}@@surviving-session`, cwd: '/tmp/surviving', title: 'shell' }
|
||||
]),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getLocalProvider: () => localProvider as never
|
||||
})
|
||||
vi.spyOn(
|
||||
runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
|
||||
'listDetectedWorktreesForResolvedRepo'
|
||||
).mockResolvedValue({
|
||||
repoId: TEST_REPO_ID,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: [{ id: survivingId }] as never
|
||||
})
|
||||
|
||||
const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
|
||||
deletedId,
|
||||
survivingId
|
||||
])
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(localProvider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@deleted-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(localProvider.shutdown).not.toHaveBeenCalledWith(
|
||||
`${survivingId}@@surviving-session`,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
// Why (#10562): the renderer purges its state regardless of the sweep result, so
|
||||
// revalidating against a cached scan (30s TTL) that still lists an already-deleted
|
||||
// directory would strand those PTYs permanently — nothing asks a second time.
|
||||
it('revalidates against a fresh scan instead of a warm worktree-scan cache', async () => {
|
||||
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{ id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' }
|
||||
]),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getLocalProvider: () => localProvider as never
|
||||
})
|
||||
|
||||
// Warm the scan cache while the worktree still exists.
|
||||
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
||||
{ path: '/tmp/deleted', head: 'abc', branch: 'd', isBare: false, isMainWorktree: false }
|
||||
])
|
||||
await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [deletedId])
|
||||
expect(localProvider.shutdown).not.toHaveBeenCalled()
|
||||
|
||||
// The worktree is now gone; the cached scan must not mask that.
|
||||
vi.mocked(listWorktrees).mockResolvedValue([])
|
||||
const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
|
||||
deletedId
|
||||
])
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(localProvider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@deleted-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not stop sessions after a non-authoritative revalidation', async () => {
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => []),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getLocalProvider: () => localProvider as never
|
||||
})
|
||||
vi.spyOn(
|
||||
runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
|
||||
'listDetectedWorktreesForResolvedRepo'
|
||||
).mockResolvedValue({
|
||||
repoId: TEST_REPO_ID,
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback',
|
||||
worktrees: []
|
||||
})
|
||||
|
||||
await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
|
||||
`${TEST_REPO_ID}::/tmp/deleted`
|
||||
])
|
||||
|
||||
expect(localProvider.listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: an explicit connection identity only narrows the selector, it must not
|
||||
// change the grammar. Treating the selector as a bare repo id made every
|
||||
// `path:`/`name:` selector fail repo_not_found on this path alone.
|
||||
it('resolves non-id selectors when a connection identity is supplied', async () => {
|
||||
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{ id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' }
|
||||
]),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const localRepo = store.getRepos()[0]
|
||||
const runtime = new OrcaRuntimeService(
|
||||
{ ...store, getRepos: () => [localRepo, { ...localRepo, connectionId: 'ssh-1' }] } as never,
|
||||
undefined,
|
||||
{ getLocalProvider: () => localProvider as never }
|
||||
)
|
||||
vi.spyOn(
|
||||
runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
|
||||
'listDetectedWorktreesForResolvedRepo'
|
||||
).mockResolvedValue({
|
||||
repoId: TEST_REPO_ID,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: []
|
||||
})
|
||||
|
||||
// `path:` is ambiguous across the two rows; connectionId null selects the local one.
|
||||
const result = await runtime.teardownMissingManagedWorktreeTerminals(
|
||||
`path:${localRepo.path}`,
|
||||
[deletedId],
|
||||
null
|
||||
)
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(localProvider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@deleted-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the connection-scoped repo when local and SSH repo ids collide', async () => {
|
||||
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => []),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const sshProvider = {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{ id: `${deletedId}@@ssh-session`, cwd: '/tmp/deleted', title: 'shell' }
|
||||
]),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const localRepo = store.getRepos()[0]
|
||||
const sshRepo = { ...localRepo, connectionId: 'ssh-1' }
|
||||
const runtime = new OrcaRuntimeService(
|
||||
{
|
||||
...store,
|
||||
getRepos: () => [localRepo, sshRepo]
|
||||
} as never,
|
||||
undefined,
|
||||
{
|
||||
getLocalProvider: () => localProvider as never,
|
||||
getSshProvider: (connectionId) =>
|
||||
connectionId === 'ssh-1' ? (sshProvider as never) : undefined
|
||||
}
|
||||
)
|
||||
vi.spyOn(
|
||||
runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
|
||||
'listDetectedWorktreesForResolvedRepo'
|
||||
).mockResolvedValue({
|
||||
repoId: TEST_REPO_ID,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: []
|
||||
})
|
||||
|
||||
await runtime.teardownMissingManagedWorktreeTerminals(TEST_REPO_ID, [deletedId], 'ssh-1')
|
||||
|
||||
expect(sshProvider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@ssh-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(localProvider.listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hydrates runtime detected lists with instance-validated legacy lineage', async () => {
|
||||
const parentPath = join(tmpdir(), 'worktree-parent')
|
||||
const childPath = join(tmpdir(), 'worktree-child')
|
||||
@@ -36064,10 +36374,13 @@ describe('OrcaRuntimeService', () => {
|
||||
(spawn.mock.calls[1]?.[0] as { env?: Record<string, string> } | undefined)?.env ?? {}
|
||||
expectStablePaneKeyEnv(setupSpawnEnv)
|
||||
const setupLeafId = setupSpawnEnv.ORCA_PANE_KEY.slice(`${setupSpawnEnv.ORCA_TAB_ID}:`.length)
|
||||
// Why: a background CLI create adopts its tabs silently — surfaceOwner:false
|
||||
// keeps the sidebar from scrolling to a workspace the user never asked for.
|
||||
expect(revealTerminalSession).toHaveBeenLastCalledWith(result.worktree.id, {
|
||||
ptyId: 'pty-setup',
|
||||
title: 'Setup',
|
||||
activate: false,
|
||||
surfaceOwner: false,
|
||||
tabId: setupSpawnEnv.ORCA_TAB_ID,
|
||||
leafId: setupLeafId
|
||||
})
|
||||
@@ -36303,15 +36616,194 @@ describe('OrcaRuntimeService', () => {
|
||||
const initialLeafId = initialSpawnEnv.ORCA_PANE_KEY.slice(
|
||||
`${initialSpawnEnv.ORCA_TAB_ID}:`.length
|
||||
)
|
||||
// Why: the renderer treats a missing surfaceOwner as "reveal the owner", which
|
||||
// scrolled the sidebar to background CLI creates.
|
||||
expect(revealTerminalSession).toHaveBeenCalledWith(result.worktree.id, {
|
||||
ptyId: 'pty-created-worktree',
|
||||
title: null,
|
||||
activate: false,
|
||||
surfaceOwner: false,
|
||||
tabId: initialSpawnEnv.ORCA_TAB_ID,
|
||||
leafId: initialLeafId
|
||||
})
|
||||
})
|
||||
|
||||
it('does not surface the new workspace when a background CLI create launches its agent', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const activateWorktree = vi.fn()
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-background-agent' })
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-background-agent' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree,
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-background-agent')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-background-agent')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-background-agent',
|
||||
head: 'def',
|
||||
branch: 'runtime-background-agent',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-background-agent',
|
||||
createdWithAgent: 'claude',
|
||||
startup: { command: 'claude' }
|
||||
})
|
||||
|
||||
// `orca worktree create --agent claude` without --activate: the agent tab is
|
||||
// adopted, but the sidebar must stay on whatever the user was reading.
|
||||
expect(activateWorktree).not.toHaveBeenCalled()
|
||||
expect(revealTerminalSession).toHaveBeenCalledWith(
|
||||
result.worktree.id,
|
||||
expect.objectContaining({
|
||||
ptyId: 'pty-background-agent',
|
||||
activate: false,
|
||||
surfaceOwner: false
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('still surfaces the new workspace when the caller explicitly activates it', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-activated-agent' })
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-activated-agent' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-activated-agent')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-activated-agent')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-activated-agent',
|
||||
head: 'def',
|
||||
branch: 'runtime-activated-agent',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-activated-agent',
|
||||
activate: true,
|
||||
createdWithAgent: 'claude',
|
||||
startup: { command: 'claude' }
|
||||
})
|
||||
|
||||
// No-regression guard: an explicit --activate still reveals the workspace, so
|
||||
// surfaceOwner must stay absent.
|
||||
const revealPayload = revealTerminalSession.mock.calls[0]?.[1] as
|
||||
| { surfaceOwner?: boolean }
|
||||
| undefined
|
||||
expect(revealTerminalSession).toHaveBeenCalledWith(
|
||||
result.worktree.id,
|
||||
expect.objectContaining({ ptyId: 'pty-activated-agent' })
|
||||
)
|
||||
expect(revealPayload).not.toHaveProperty('surfaceOwner')
|
||||
})
|
||||
|
||||
it('still surfaces the new workspace when hooks run in the foreground', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-hooks-agent' })
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-hooks-agent' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hooks-agent')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hooks-agent')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-hooks-agent',
|
||||
head: 'def',
|
||||
branch: 'runtime-hooks-agent',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-hooks-agent',
|
||||
runHooks: true,
|
||||
createdWithAgent: 'claude',
|
||||
startup: { command: 'claude' }
|
||||
})
|
||||
|
||||
const revealPayload = revealTerminalSession.mock.calls[0]?.[1] as
|
||||
| { surfaceOwner?: boolean }
|
||||
| undefined
|
||||
expect(revealTerminalSession).toHaveBeenCalledWith(
|
||||
result.worktree.id,
|
||||
expect.objectContaining({ ptyId: 'pty-hooks-agent' })
|
||||
)
|
||||
expect(revealPayload).not.toHaveProperty('surfaceOwner')
|
||||
})
|
||||
|
||||
it('honors split setup placement for CLI-created worktrees without startup agents', async () => {
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -36400,6 +36892,172 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not surface the new workspace when a background create splits its setup pane', async () => {
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
setupScriptLaunchMode: 'split-horizontal' as const
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const activateWorktree = vi.fn()
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg-setup-split' })
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-bg-split-main' })
|
||||
.mockResolvedValueOnce({ id: 'pty-bg-split-setup' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree,
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-bg-split-setup')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-bg-split-setup')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-bg-split-setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-bg-split-setup',
|
||||
head: 'def',
|
||||
branch: 'runtime-bg-split-setup',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-bg-split-setup',
|
||||
setupDecision: 'run'
|
||||
})
|
||||
|
||||
// Split launch modes reach the renderer through splitTerminal, not
|
||||
// createTerminal — that path must suppress owner surfacing too.
|
||||
expect(activateWorktree).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record<string, string> }).env ?? {}
|
||||
await vi.waitFor(() =>
|
||||
expect(revealTerminalSession).toHaveBeenLastCalledWith(
|
||||
result.worktree.id,
|
||||
expect.objectContaining({
|
||||
ptyId: 'pty-bg-split-setup',
|
||||
tabId: mainEnv.ORCA_TAB_ID,
|
||||
activate: false,
|
||||
surfaceOwner: false,
|
||||
splitDirection: 'horizontal'
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('still surfaces the new workspace when an activating create splits its setup pane', async () => {
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
setupScriptLaunchMode: 'split-horizontal' as const
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-active-setup-split' })
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-active-split-main' })
|
||||
.mockResolvedValueOnce({ id: 'pty-active-split-setup' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-active-split-setup')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-active-split-setup')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-active-split-setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-active-split-setup',
|
||||
head: 'def',
|
||||
branch: 'runtime-active-split-setup',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-active-split-setup',
|
||||
activate: true,
|
||||
setupDecision: 'run',
|
||||
createdWithAgent: 'claude',
|
||||
startup: { command: 'claude' }
|
||||
})
|
||||
|
||||
// No-regression guard: an explicit --activate still reveals the workspace.
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() =>
|
||||
expect(revealTerminalSession).toHaveBeenLastCalledWith(
|
||||
result.worktree.id,
|
||||
expect.objectContaining({
|
||||
ptyId: 'pty-active-split-setup',
|
||||
splitDirection: 'horizontal'
|
||||
})
|
||||
)
|
||||
)
|
||||
const splitRevealPayload = revealTerminalSession.mock.lastCall?.[1] as
|
||||
| { surfaceOwner?: boolean }
|
||||
| undefined
|
||||
expect(splitRevealPayload).not.toHaveProperty('surfaceOwner')
|
||||
})
|
||||
|
||||
it('does not warn when setup is explicitly skipped for CLI-created worktrees', async () => {
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
|
||||
@@ -324,6 +324,7 @@ import {
|
||||
type RuntimeMobileSessionTabsRemovedResult,
|
||||
type RuntimeMobileSessionTabsResult,
|
||||
type RuntimeMobileSessionTabsSnapshot,
|
||||
type RuntimeNativeChatLaunchDraftResolution,
|
||||
type RuntimeSessionTabCloseReason,
|
||||
type RuntimeBrowserDriverState,
|
||||
type RuntimeTerminalDriverState,
|
||||
@@ -929,6 +930,7 @@ import {
|
||||
registerTerminalViewAttributesApplier
|
||||
} from './terminal-view-attribute-store'
|
||||
import { killAllProcessesForWorktree, teardownRpcDeadline } from './worktree-teardown'
|
||||
import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation'
|
||||
import {
|
||||
MobileNotificationReplayBuffer,
|
||||
type ReplayableMobileNotification
|
||||
@@ -1271,6 +1273,10 @@ type TerminalCreateOptions = {
|
||||
rendererBacked?: boolean
|
||||
activate?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
// Why: `false` adopts the terminal without pointing the user at it — no
|
||||
// sidebar reveal, no tab focus. Distinct from 'background' presentation,
|
||||
// which skips renderer adoption entirely.
|
||||
surfaceOwner?: false
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
sessionId?: string
|
||||
@@ -1654,6 +1660,12 @@ function createTerminalRevealWarning(handle: string, error?: unknown): string {
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
// Why: an absent `surfaceOwner` means "default", so surfacing callers must omit
|
||||
// the key rather than send `true`.
|
||||
function ownerSurfacing(shouldSurface: boolean): { surfaceOwner?: false } {
|
||||
return shouldSurface ? {} : { surfaceOwner: false }
|
||||
}
|
||||
|
||||
function resolveTerminalPresentation(opts: {
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
focus?: boolean
|
||||
@@ -1702,6 +1714,7 @@ type RuntimeNotifier = {
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
activate?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
surfaceOwner?: false
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
splitFromLeafId?: string
|
||||
@@ -1777,6 +1790,10 @@ type RuntimeNotifier = {
|
||||
// and so a future write coordinator can use the same signal as scheduling
|
||||
// input. See docs/mobile-presence-lock.md.
|
||||
terminalDriverChanged(ptyId: string, driver: DriverState): void
|
||||
nativeChatLaunchDraftResolved?(
|
||||
tabId: string,
|
||||
resolution: { text: string; createdAt: number }
|
||||
): void
|
||||
browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void
|
||||
}
|
||||
|
||||
@@ -2582,6 +2599,12 @@ type LayoutQueueEntry = {
|
||||
}[]
|
||||
}
|
||||
|
||||
type NativeChatLaunchDraftResolutionTombstone = RuntimeNativeChatLaunchDraftResolution & {
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
const MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES = 200
|
||||
|
||||
async function hasLocalWorktreeBaseRef(
|
||||
repoPath: string,
|
||||
baseRef: string,
|
||||
@@ -2702,6 +2725,10 @@ export class OrcaRuntimeService {
|
||||
private ptyController: RuntimePtyController | null = null
|
||||
private notifier: RuntimeNotifier | null = null
|
||||
private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>()
|
||||
private nativeChatLaunchDraftResolutionByTabId = new Map<
|
||||
string,
|
||||
NativeChatLaunchDraftResolutionTombstone
|
||||
>()
|
||||
private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>()
|
||||
private forkBackfillStarted = false
|
||||
private agentBrowserBridge: AgentBrowserBridge | null = null
|
||||
@@ -4705,12 +4732,146 @@ export class OrcaRuntimeService {
|
||||
return events
|
||||
}
|
||||
|
||||
getNativeChatLaunchDraftResolutionClientEventSnapshot(): Extract<
|
||||
RuntimeClientEvent,
|
||||
{ type: 'nativeChatLaunchDraftResolved' }
|
||||
>[] {
|
||||
return [...this.nativeChatLaunchDraftResolutionByTabId.values()]
|
||||
.sort((a, b) => a.tabId.localeCompare(b.tabId))
|
||||
.map(({ tabId, text, createdAt }) => ({
|
||||
type: 'nativeChatLaunchDraftResolved',
|
||||
tabId,
|
||||
text,
|
||||
createdAt
|
||||
}))
|
||||
}
|
||||
|
||||
private emitClientEvent(event: RuntimeClientEvent): void {
|
||||
// Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the
|
||||
// per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart.
|
||||
notifyRuntimeListeners(this.clientEventListeners, (listener) => listener(event), 'client-event')
|
||||
}
|
||||
|
||||
notifyNativeChatLaunchDraftResolved(
|
||||
handle: string,
|
||||
resolution: { text: string; createdAt: number }
|
||||
): void {
|
||||
const owner = this.resolveNativeChatLaunchDraftOwner(handle)
|
||||
if (!owner) {
|
||||
return
|
||||
}
|
||||
const tombstone = { ...owner, ...resolution }
|
||||
this.nativeChatLaunchDraftResolutionByTabId.delete(owner.tabId)
|
||||
this.nativeChatLaunchDraftResolutionByTabId.set(owner.tabId, tombstone)
|
||||
while (
|
||||
this.nativeChatLaunchDraftResolutionByTabId.size >
|
||||
MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES
|
||||
) {
|
||||
const oldestTabId = this.nativeChatLaunchDraftResolutionByTabId.keys().next().value
|
||||
if (typeof oldestTabId !== 'string') {
|
||||
break
|
||||
}
|
||||
this.nativeChatLaunchDraftResolutionByTabId.delete(oldestTabId)
|
||||
}
|
||||
this.retireResolvedNativeChatLaunchDraftFromMobileSnapshot(tombstone)
|
||||
this.notifier?.nativeChatLaunchDraftResolved?.(owner.tabId, resolution)
|
||||
this.emitClientEvent({
|
||||
type: 'nativeChatLaunchDraftResolved',
|
||||
tabId: owner.tabId,
|
||||
...resolution
|
||||
})
|
||||
}
|
||||
|
||||
private resolveNativeChatLaunchDraftOwner(
|
||||
handle: string
|
||||
): { tabId: string; worktreeId: string } | null {
|
||||
const record = this.handles.get(handle)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
if (!record.tabId.startsWith('pty:')) {
|
||||
return { tabId: record.tabId, worktreeId: record.worktreeId }
|
||||
}
|
||||
const pty = record.ptyId ? this.ptysById.get(record.ptyId) : null
|
||||
const tabId =
|
||||
pty?.tabId && !pty.tabId.startsWith('pty:')
|
||||
? pty.tabId
|
||||
: parsePaneKey(pty?.paneKey ?? '')?.tabId
|
||||
if (!pty || !tabId || tabId.startsWith('pty:')) {
|
||||
return null
|
||||
}
|
||||
return { tabId, worktreeId: pty.worktreeId }
|
||||
}
|
||||
|
||||
private retireResolvedNativeChatLaunchDraftFromMobileSnapshot(
|
||||
resolution: NativeChatLaunchDraftResolutionTombstone
|
||||
): void {
|
||||
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
|
||||
if (!runtimeWorktreeIdsEqual(worktreeId, resolution.worktreeId)) {
|
||||
continue
|
||||
}
|
||||
const next = this.applyNativeChatLaunchDraftResolutionFence(snapshot)
|
||||
if (next === snapshot) {
|
||||
return
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, {
|
||||
...next,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1
|
||||
})
|
||||
this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private applyNativeChatLaunchDraftResolutionFence(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot
|
||||
): RuntimeMobileSessionTabsSnapshot {
|
||||
let changed = false
|
||||
const tabs = snapshot.tabs.map((tab) => {
|
||||
if (tab.type !== 'terminal') {
|
||||
return tab
|
||||
}
|
||||
const resolution = this.nativeChatLaunchDraftResolutionByTabId.get(tab.parentTabId)
|
||||
if (
|
||||
!resolution ||
|
||||
!runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId) ||
|
||||
tab.launchDraft !== resolution.text ||
|
||||
tab.launchDraftCreatedAt !== resolution.createdAt
|
||||
) {
|
||||
return tab
|
||||
}
|
||||
changed = true
|
||||
const next = { ...tab }
|
||||
delete next.launchDraft
|
||||
delete next.launchDraftCreatedAt
|
||||
return next
|
||||
})
|
||||
return changed ? { ...snapshot, tabs } : snapshot
|
||||
}
|
||||
|
||||
private reconcileNativeChatLaunchDraftResolutionTombstones(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot
|
||||
): void {
|
||||
for (const [tabId, resolution] of this.nativeChatLaunchDraftResolutionByTabId) {
|
||||
if (!runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId)) {
|
||||
continue
|
||||
}
|
||||
const surfaces = snapshot.tabs.filter(
|
||||
(tab): tab is RuntimeMobileSessionTerminalTab =>
|
||||
tab.type === 'terminal' && tab.parentTabId === tabId
|
||||
)
|
||||
if (
|
||||
surfaces.length === 0 ||
|
||||
!surfaces.some(
|
||||
(tab) =>
|
||||
tab.launchDraft === resolution.text && tab.launchDraftCreatedAt === resolution.createdAt
|
||||
)
|
||||
) {
|
||||
this.nativeChatLaunchDraftResolutionByTabId.delete(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private notifyWorktreesChanged(repoId: string): void {
|
||||
this.notifier?.worktreesChanged(repoId)
|
||||
this.emitClientEvent({ type: 'worktreesChanged', repoId })
|
||||
@@ -5174,9 +5335,14 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
const agentOrchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey()
|
||||
const nativeChatLaunchDraftResolutions =
|
||||
this.getNativeChatLaunchDraftResolutionClientEventSnapshot().map(
|
||||
({ tabId, text, createdAt }) => ({ tabId, text, createdAt })
|
||||
)
|
||||
return {
|
||||
...this.getStatus(),
|
||||
...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {})
|
||||
...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}),
|
||||
...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19425,8 +19591,18 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
async listDetectedManagedWorktrees(
|
||||
repoSelector: string,
|
||||
connectionId?: string | null
|
||||
): Promise<DetectedWorktreeListResult> {
|
||||
return this.listDetectedWorktreesForResolvedRepo(
|
||||
await this.resolveRepoSelectorForConnection(repoSelector, connectionId)
|
||||
)
|
||||
}
|
||||
|
||||
private async listDetectedWorktreesForResolvedRepo(
|
||||
repo: Repo
|
||||
): Promise<DetectedWorktreeListResult> {
|
||||
const store = this.requireStore()
|
||||
if (isFolderRepo(repo)) {
|
||||
const worktrees = listRuntimeFolderWorkspaces(store, repo)
|
||||
@@ -19476,6 +19652,56 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async teardownMissingManagedWorktreeTerminals(
|
||||
repoSelector: string,
|
||||
knownWorktreeIds: readonly string[],
|
||||
connectionId?: string | null
|
||||
): Promise<{ stoppedWorktreeIds: string[] }> {
|
||||
const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId)
|
||||
// Why: killing PTYs must be proven against the host right now — a cached scan
|
||||
// (30s TTL) can still list a directory git already dropped, and the renderer
|
||||
// purges its state either way, so a stale miss strands those processes for good.
|
||||
this.invalidateWorktreeScanCacheForRepo(repo.id)
|
||||
// Why: rescanning by `id:` would re-resolve the already-resolved repo, and a
|
||||
// duplicate id across hosts makes that second lookup throw selector_ambiguous
|
||||
// even though the caller's selector was unique — losing the sweep entirely.
|
||||
const detected = await this.listDetectedWorktreesForResolvedRepo(repo)
|
||||
if (!detected.authoritative) {
|
||||
return { stoppedWorktreeIds: [] }
|
||||
}
|
||||
return stopMissingWorktreeTerminals(
|
||||
repo,
|
||||
knownWorktreeIds,
|
||||
detected.worktrees.map((worktree) => worktree.id),
|
||||
{
|
||||
runtime: this,
|
||||
getLocalProvider: () => this.getLocalProvider(),
|
||||
getSshProvider: (connectionId) => this.getSshProviderFn?.(connectionId),
|
||||
onPtyStopped: this.onPtyStopped ?? undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private resolveRepoSelectorForConnection(
|
||||
repoSelector: string,
|
||||
connectionId?: string | null
|
||||
): Promise<Repo> {
|
||||
if (connectionId === undefined) {
|
||||
return this.resolveRepoSelector(repoSelector)
|
||||
}
|
||||
// Why: an explicit connection identity only *narrows* the selector; it must not
|
||||
// change the grammar. Matching the selector as a bare repo id would make
|
||||
// `path:`/`name:` selectors resolve to repo_not_found on this path alone.
|
||||
const wanted = connectionId?.trim() || null
|
||||
const matches = this.selectReposBySelector(repoSelector).filter(
|
||||
(repo) => (repo.connectionId?.trim() || null) === wanted
|
||||
)
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(matches.length > 1 ? 'selector_ambiguous' : 'repo_not_found')
|
||||
}
|
||||
return Promise.resolve(matches[0])
|
||||
}
|
||||
|
||||
private isRuntimeWorktreeVisible(
|
||||
worktree: Worktree,
|
||||
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
|
||||
@@ -19942,7 +20168,8 @@ export class OrcaRuntimeService {
|
||||
private async createDefaultTabTerminals(
|
||||
worktreeSelector: string,
|
||||
worktreeId: string,
|
||||
defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined
|
||||
defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined,
|
||||
surfacing: { surfaceOwner?: false } = {}
|
||||
): Promise<string[]> {
|
||||
if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) {
|
||||
return []
|
||||
@@ -19953,7 +20180,8 @@ export class OrcaRuntimeService {
|
||||
const command = template.command?.trim()
|
||||
const terminal = await this.createTerminal(worktreeSelector, {
|
||||
...(template.title ? { title: template.title } : {}),
|
||||
...(command && defaultTabs.runCommands ? { command } : {})
|
||||
...(command && defaultTabs.runCommands ? { command } : {}),
|
||||
...surfacing
|
||||
})
|
||||
handles.push(terminal.handle)
|
||||
if (template.color && terminal.tabId) {
|
||||
@@ -19984,17 +20212,22 @@ export class OrcaRuntimeService {
|
||||
// the setup command. Pass that wrapped command through so the Setup tab runs
|
||||
// the same script the agent is waiting on instead of a bare runner.
|
||||
wrappedSetupCommand?: string
|
||||
// Why: a workspace provisioned in the background must not pull the sidebar
|
||||
// to itself; the user never asked to look at these tabs.
|
||||
surfaceOwner?: false
|
||||
}): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> {
|
||||
if (!this.ptyController?.spawn) {
|
||||
return { setupSpawned: false, setupTerminalHandle: null }
|
||||
}
|
||||
const surfacing = ownerSurfacing(args.surfaceOwner !== false)
|
||||
let setupSpawned = false
|
||||
let setupTerminalHandle: string | null = null
|
||||
try {
|
||||
const defaultTabHandles = await this.createDefaultTabTerminals(
|
||||
args.worktreeSelector,
|
||||
args.worktreeId,
|
||||
args.defaultTabs
|
||||
args.defaultTabs,
|
||||
surfacing
|
||||
)
|
||||
let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null
|
||||
const setupLaunchMode =
|
||||
@@ -20004,7 +20237,7 @@ export class OrcaRuntimeService {
|
||||
>
|
||||
).setupScriptLaunchMode ?? 'new-tab'
|
||||
if (!args.hasStartupTerminal && !primaryTerminalHandle) {
|
||||
const terminal = await this.createTerminal(args.worktreeSelector)
|
||||
const terminal = await this.createTerminal(args.worktreeSelector, surfacing)
|
||||
primaryTerminalHandle = terminal.handle
|
||||
}
|
||||
if (args.setup) {
|
||||
@@ -20030,12 +20263,14 @@ export class OrcaRuntimeService {
|
||||
direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
|
||||
command: setupCommand,
|
||||
env: setupEnv,
|
||||
activate: false
|
||||
activate: false,
|
||||
...surfacing
|
||||
})
|
||||
: this.createTerminal(args.worktreeSelector, {
|
||||
title: 'Setup',
|
||||
command: setupCommand,
|
||||
env: setupEnv
|
||||
env: setupEnv,
|
||||
...surfacing
|
||||
}))
|
||||
setupTerminalHandle = setupTerminal.handle
|
||||
setupSpawned = true
|
||||
@@ -20314,7 +20549,8 @@ export class OrcaRuntimeService {
|
||||
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
|
||||
...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}),
|
||||
startupCommandDelivery: effectiveStartup.startupCommandDelivery,
|
||||
telemetry: effectiveStartup.telemetry
|
||||
telemetry: effectiveStartup.telemetry,
|
||||
...ownerSurfacing(shouldActivate)
|
||||
})
|
||||
if (effectiveDraftPaste) {
|
||||
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
|
||||
@@ -20345,7 +20581,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
} else if (this.ptyController?.spawn && !didSpawnStartup) {
|
||||
try {
|
||||
await this.createTerminal(`id:${worktree.id}`)
|
||||
await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warning = warning
|
||||
@@ -21054,7 +21290,8 @@ export class OrcaRuntimeService {
|
||||
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
|
||||
...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}),
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry
|
||||
telemetry: sequencedStartup.telemetry,
|
||||
...ownerSurfacing(shouldActivate)
|
||||
})
|
||||
if (effectiveDraftPaste) {
|
||||
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
|
||||
@@ -21155,7 +21392,8 @@ export class OrcaRuntimeService {
|
||||
: 'posix'
|
||||
: 'posix',
|
||||
observeSetupCompletion: args.observeSetupCompletion,
|
||||
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
|
||||
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}),
|
||||
surfaceOwner: false
|
||||
})
|
||||
// Why: runtime owns setup spawning here, so the RPC result must omit setup
|
||||
// to keep the headless/mobile caller from launching it a second time.
|
||||
@@ -21171,7 +21409,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
} else if (this.ptyController?.spawn) {
|
||||
try {
|
||||
await this.createTerminal(`id:${worktree.id}`)
|
||||
await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warning = warning
|
||||
@@ -21355,6 +21593,7 @@ export class OrcaRuntimeService {
|
||||
this.invalidateWorktreeScanCacheForRepo(repo.id)
|
||||
this.notifyWorktreesChanged(repo.id)
|
||||
|
||||
const shouldActivate = args.activate === true || args.runHooks === true
|
||||
let warning = result.warning
|
||||
let didSpawnStartup = false
|
||||
// Why: same no-double-spawn contract as the local path — once runtime
|
||||
@@ -21403,7 +21642,8 @@ export class OrcaRuntimeService {
|
||||
...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}),
|
||||
...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}),
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry
|
||||
telemetry: sequencedStartup.telemetry,
|
||||
...ownerSurfacing(shouldActivate)
|
||||
})
|
||||
if (args.startupDraftPaste) {
|
||||
this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste)
|
||||
@@ -21424,7 +21664,6 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
const shouldActivate = args.activate === true || args.runHooks === true
|
||||
if (shouldActivate) {
|
||||
const runtimeWillProvisionTerminals =
|
||||
didSpawnStartup && Boolean(result.setup || result.defaultTabs)
|
||||
@@ -21506,7 +21745,8 @@ export class OrcaRuntimeService {
|
||||
: 'posix'
|
||||
: 'posix',
|
||||
observeSetupCompletion: args.observeSetupCompletion,
|
||||
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
|
||||
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}),
|
||||
surfaceOwner: false
|
||||
})
|
||||
// Why: runtime owns setup spawning here, so omit setup from the RPC result
|
||||
// to keep the headless/mobile caller from launching it a second time.
|
||||
@@ -21522,7 +21762,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
} else if (!shouldActivate && this.ptyController?.spawn) {
|
||||
try {
|
||||
await this.createTerminal(`path:${result.worktree.path}`)
|
||||
await this.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warning = warning
|
||||
@@ -23948,6 +24188,7 @@ export class OrcaRuntimeService {
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {}),
|
||||
...ownerSurfacing(opts.surfaceOwner !== false),
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
@@ -24032,7 +24273,8 @@ export class OrcaRuntimeService {
|
||||
startupCommandDelivery: launchOpts.startupCommandDelivery,
|
||||
title: launchOpts.title,
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {})
|
||||
...(presentation ? { presentation } : {}),
|
||||
...ownerSurfacing(opts.surfaceOwner !== false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25172,6 +25414,9 @@ export class OrcaRuntimeService {
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
activate?: boolean
|
||||
// Why: same split as createTerminal — adopt the pane without revealing its
|
||||
// workspace, for splits the user never asked to see.
|
||||
surfaceOwner?: false
|
||||
telemetrySource?: TerminalPaneSplitSource
|
||||
} = {}
|
||||
): Promise<RuntimeTerminalSplit> {
|
||||
@@ -25209,6 +25454,9 @@ export class OrcaRuntimeService {
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
activate?: boolean
|
||||
// Why: same split as createTerminal — adopt the pane without revealing its
|
||||
// workspace, for splits the user never asked to see.
|
||||
surfaceOwner?: false
|
||||
telemetrySource?: TerminalPaneSplitSource
|
||||
} = {}
|
||||
): Promise<RuntimeTerminalSplit> {
|
||||
@@ -25259,6 +25507,7 @@ export class OrcaRuntimeService {
|
||||
ptyId: result.id,
|
||||
title: null,
|
||||
activate: opts.activate !== false,
|
||||
...ownerSurfacing(opts.surfaceOwner !== false),
|
||||
tabId: parentTabId,
|
||||
leafId,
|
||||
splitFromLeafId: parsedPaneKey.leafId,
|
||||
@@ -26684,27 +26933,32 @@ export class OrcaRuntimeService {
|
||||
return this.store?.getAllWorkspaceLineage?.() ?? {}
|
||||
}
|
||||
|
||||
// Why: one selector grammar, so connection-scoped resolution can narrow the same
|
||||
// candidate set instead of reimplementing (and diverging from) the matching rules.
|
||||
private selectReposBySelector(selector: string): Repo[] {
|
||||
const repos = this.store?.getRepos() ?? []
|
||||
if (selector.startsWith('id:')) {
|
||||
return repos.filter((repo) => repo.id === selector.slice(3))
|
||||
}
|
||||
if (selector.startsWith('path:')) {
|
||||
return repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5)))
|
||||
}
|
||||
if (selector.startsWith('name:')) {
|
||||
return repos.filter((repo) => repo.displayName === selector.slice(5))
|
||||
}
|
||||
return repos.filter(
|
||||
(repo) =>
|
||||
repo.id === selector ||
|
||||
runtimePathsEqual(repo.path, selector) ||
|
||||
repo.displayName === selector
|
||||
)
|
||||
}
|
||||
|
||||
private async resolveRepoSelector(selector: string): Promise<Repo> {
|
||||
if (!this.store) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
const repos = this.store.getRepos()
|
||||
let candidates: Repo[]
|
||||
|
||||
if (selector.startsWith('id:')) {
|
||||
candidates = repos.filter((repo) => repo.id === selector.slice(3))
|
||||
} else if (selector.startsWith('path:')) {
|
||||
candidates = repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5)))
|
||||
} else if (selector.startsWith('name:')) {
|
||||
candidates = repos.filter((repo) => repo.displayName === selector.slice(5))
|
||||
} else {
|
||||
candidates = repos.filter(
|
||||
(repo) =>
|
||||
repo.id === selector ||
|
||||
runtimePathsEqual(repo.path, selector) ||
|
||||
repo.displayName === selector
|
||||
)
|
||||
}
|
||||
const candidates = this.selectReposBySelector(selector)
|
||||
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0]
|
||||
@@ -27790,7 +28044,9 @@ export class OrcaRuntimeService {
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const fencedSnapshot = this.applyMobileSessionRetirementFences(snapshot)
|
||||
this.reconcileNativeChatLaunchDraftResolutionTombstones(snapshot)
|
||||
const launchDraftFencedSnapshot = this.applyNativeChatLaunchDraftResolutionFence(snapshot)
|
||||
const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot)
|
||||
const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing)
|
||||
// Why: clients drop same-epoch frames whose version isn't strictly newer,
|
||||
// and main-local touches may already have emitted a higher version than
|
||||
@@ -28405,6 +28661,9 @@ export class OrcaRuntimeService {
|
||||
...(tab.isPinned ? { isPinned: true } : {}),
|
||||
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
|
||||
...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}),
|
||||
...(tab.launchDraftCreatedAt !== undefined
|
||||
? { launchDraftCreatedAt: tab.launchDraftCreatedAt }
|
||||
: {}),
|
||||
isActive: tab.isActive,
|
||||
...(terminalHandle
|
||||
? { status: 'ready' as const, terminal: terminalHandle }
|
||||
|
||||
@@ -42,6 +42,7 @@ import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-c
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { OrchestrationError } from './orchestration-error'
|
||||
import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew'
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../shared/orchestration-run-pagination'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
|
||||
// Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys.
|
||||
@@ -158,6 +159,28 @@ function exposeRunTimestamps(run: RunRow): RunRow {
|
||||
}
|
||||
}
|
||||
|
||||
function encodeRunListCursor(run: RunRow): string {
|
||||
const cursor: RunListCursor = { createdAt: run.created_at, id: run.id }
|
||||
return Buffer.from(JSON.stringify(cursor)).toString('base64url')
|
||||
}
|
||||
|
||||
function decodeRunListCursor(value: string): RunListCursor {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof (parsed as RunListCursor).createdAt !== 'string' ||
|
||||
typeof (parsed as RunListCursor).id !== 'string'
|
||||
) {
|
||||
throw new Error('invalid cursor shape')
|
||||
}
|
||||
return parsed as RunListCursor
|
||||
} catch {
|
||||
throw new OrchestrationError('cursor_invalid', 'The Run list cursor is invalid.')
|
||||
}
|
||||
}
|
||||
|
||||
function exposeDeliveryTimestamps(delivery: DeliveryRow): DeliveryRow {
|
||||
return {
|
||||
...delivery,
|
||||
@@ -213,8 +236,21 @@ export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID
|
||||
export const LEGACY_CONTRACT_VERSION = 0
|
||||
export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION
|
||||
|
||||
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance.
|
||||
const SCHEMA_VERSION = 21
|
||||
const MUTATION_RECEIPT_MAX_ROWS = 10_000
|
||||
const MUTATION_RECEIPT_MAX_AGE_DAYS = 30
|
||||
|
||||
export type RunListPage = {
|
||||
runs: RunRow[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
type RunListCursor = {
|
||||
createdAt: string
|
||||
id: string
|
||||
}
|
||||
|
||||
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup.
|
||||
const SCHEMA_VERSION = 22
|
||||
|
||||
function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void {
|
||||
if (dbPath === ':memory:' || process.platform === 'win32') {
|
||||
@@ -457,6 +493,7 @@ export class OrchestrationDb {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_status ON dispatch_contexts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle ON dispatch_contexts(assignee_handle);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_gates (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -835,6 +872,12 @@ export class OrchestrationDb {
|
||||
if (current < 21) {
|
||||
this.migrateLegacySchedulerLossProvenance()
|
||||
}
|
||||
if (current < 22) {
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle
|
||||
ON dispatch_contexts(assignee_handle);
|
||||
`)
|
||||
}
|
||||
this.createUndeliveredInboxIndexIfPossible()
|
||||
|
||||
this.db.pragma(`user_version = ${SCHEMA_VERSION}`)
|
||||
@@ -1245,6 +1288,44 @@ export class OrchestrationDb {
|
||||
|
||||
// ── Durable mutation receipts ──
|
||||
|
||||
private ensureMutationReceiptCapacity(): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM mutation_receipts
|
||||
WHERE state = 'completed'
|
||||
AND updated_at < datetime('now', ?)`
|
||||
)
|
||||
.run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`)
|
||||
|
||||
const row = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as {
|
||||
count: number
|
||||
}
|
||||
const completedToRemove = row.count - MUTATION_RECEIPT_MAX_ROWS + 1
|
||||
if (completedToRemove > 0) {
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM mutation_receipts
|
||||
WHERE rowid IN (
|
||||
SELECT rowid FROM mutation_receipts
|
||||
WHERE state = 'completed'
|
||||
ORDER BY updated_at ASC, rowid ASC
|
||||
LIMIT ?
|
||||
)`
|
||||
)
|
||||
.run(completedToRemove)
|
||||
}
|
||||
|
||||
const retained = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as {
|
||||
count: number
|
||||
}
|
||||
if (retained.count >= MUTATION_RECEIPT_MAX_ROWS) {
|
||||
throw new OrchestrationError(
|
||||
'mutation_ledger_full',
|
||||
'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
beginMutationReceipt(params: {
|
||||
callerFingerprint: string
|
||||
requestId: string
|
||||
@@ -1267,6 +1348,7 @@ export class OrchestrationDb {
|
||||
this.db.exec('COMMIT')
|
||||
return { disposition: existing.state, row: existing }
|
||||
}
|
||||
this.ensureMutationReceiptCapacity()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mutation_receipts (
|
||||
@@ -2221,10 +2303,38 @@ export class OrchestrationDb {
|
||||
return run ? exposeRunTimestamps(run) : undefined
|
||||
}
|
||||
|
||||
listRuns(): RunRow[] {
|
||||
return (this.db.prepare('SELECT * FROM runs ORDER BY created_at DESC').all() as RunRow[]).map(
|
||||
exposeRunTimestamps
|
||||
listRuns(params: { limit?: number; cursor?: string } = {}): RunListPage {
|
||||
if (params.limit === undefined && params.cursor === undefined) {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC')
|
||||
.all() as RunRow[]
|
||||
return { runs: rows.map(exposeRunTimestamps), nextCursor: null }
|
||||
}
|
||||
const limit = Math.min(
|
||||
Math.max(1, params.limit ?? ORCHESTRATION_RUN_PAGE_LIMIT),
|
||||
ORCHESTRATION_RUN_PAGE_LIMIT
|
||||
)
|
||||
const cursor = params.cursor ? decodeRunListCursor(params.cursor) : undefined
|
||||
const rows = (
|
||||
cursor
|
||||
? this.db
|
||||
.prepare(
|
||||
`SELECT * FROM runs
|
||||
WHERE created_at < ? OR (created_at = ? AND id < ?)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(cursor.createdAt, cursor.createdAt, cursor.id, limit + 1)
|
||||
: this.db
|
||||
.prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?')
|
||||
.all(limit + 1)
|
||||
) as RunRow[]
|
||||
const hasMore = rows.length > limit
|
||||
const pageRows = hasMore ? rows.slice(0, limit) : rows
|
||||
return {
|
||||
runs: pageRows.map(exposeRunTimestamps),
|
||||
nextCursor: hasMore ? encodeRunListCursor(pageRows.at(-1) as RunRow) : null
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentRunForPane(paneKey: string): RunRow | undefined {
|
||||
@@ -3748,6 +3858,7 @@ export class OrchestrationDb {
|
||||
`Mutation ${receipt.requestId} already has a durable acceptance record.`
|
||||
)
|
||||
}
|
||||
this.ensureMutationReceiptCapacity()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mutation_receipts (
|
||||
@@ -4335,6 +4446,7 @@ export class OrchestrationDb {
|
||||
`Remote attachment request ${params.mutationReceipt.requestId} already exists.`
|
||||
)
|
||||
}
|
||||
this.ensureMutationReceiptCapacity()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mutation_receipts (
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrchestrationDb } from './db'
|
||||
|
||||
const MUTATION_RECEIPT_MAX_ROWS = 10_000
|
||||
|
||||
function sqliteFor(db: OrchestrationDb): Database.Database {
|
||||
return (db as unknown as { db: Database.Database }).db
|
||||
}
|
||||
|
||||
function insertMutationReceipts(
|
||||
db: OrchestrationDb,
|
||||
count: number,
|
||||
state: 'pending' | 'completed'
|
||||
): void {
|
||||
sqliteFor(db)
|
||||
.prepare(
|
||||
`WITH RECURSIVE receipt_numbers(value) AS (
|
||||
VALUES (1)
|
||||
UNION ALL
|
||||
SELECT value + 1 FROM receipt_numbers WHERE value < ?
|
||||
)
|
||||
INSERT INTO mutation_receipts (
|
||||
caller_fingerprint, request_id, method, payload_hash, state
|
||||
)
|
||||
SELECT 'caller', printf('request_%05d', value), 'orchestration.send',
|
||||
printf('hash_%05d', value), ?
|
||||
FROM receipt_numbers`
|
||||
)
|
||||
.run(count, state)
|
||||
}
|
||||
|
||||
describe('OrchestrationDb bounded mutation receipts', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('prunes expired completed receipts but preserves unresolved receipts', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const sqlite = sqliteFor(db)
|
||||
sqlite.exec(`
|
||||
INSERT INTO mutation_receipts (
|
||||
caller_fingerprint, request_id, method, payload_hash, state, updated_at
|
||||
) VALUES
|
||||
('caller', 'expired', 'orchestration.send', 'hash_expired', 'completed', '2000-01-01 00:00:00'),
|
||||
('caller', 'unresolved', 'orchestration.send', 'hash_unresolved', 'pending', '2000-01-01 00:00:00');
|
||||
`)
|
||||
|
||||
db.beginMutationReceipt({
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'new',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'hash_new'
|
||||
})
|
||||
|
||||
expect(db.getMutationReceipt('caller', 'expired')).toBeUndefined()
|
||||
expect(db.getMutationReceipt('caller', 'unresolved')).toMatchObject({ state: 'pending' })
|
||||
})
|
||||
|
||||
it('caps completed receipt count while retaining the newest replay records', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'completed')
|
||||
|
||||
db.beginMutationReceipt({
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'new',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'hash_new'
|
||||
})
|
||||
|
||||
const count = sqliteFor(db)
|
||||
.prepare('SELECT COUNT(*) AS count FROM mutation_receipts')
|
||||
.get() as { count: number }
|
||||
expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS)
|
||||
expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined()
|
||||
expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' })
|
||||
expect(db.getMutationReceipt('caller', 'new')).toMatchObject({ state: 'pending' })
|
||||
})
|
||||
|
||||
it('fails closed when unresolved receipts alone fill the ledger', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending')
|
||||
|
||||
expect(() =>
|
||||
db!.beginMutationReceipt({
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'overflow',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'hash_overflow'
|
||||
})
|
||||
).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' }))
|
||||
expect(db.getMutationReceipt('caller', 'request_00001')).toMatchObject({ state: 'pending' })
|
||||
expect(db.getMutationReceipt('caller', 'overflow')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prunes completed receipts before accepting a remote attachment', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'completed')
|
||||
|
||||
db.createRemoteDispatchAttachment({
|
||||
dispatchId: 'ctx_remote_pruned',
|
||||
taskId: 'task_remote_pruned',
|
||||
homePeerFingerprint: 'caller',
|
||||
protocolVersion: 1,
|
||||
runtimeEpoch: 'worker_epoch',
|
||||
mutationReceipt: {
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'remote_pruned',
|
||||
method: 'orchestration.federationAttachStart',
|
||||
payloadHash: 'hash_remote_pruned'
|
||||
}
|
||||
})
|
||||
|
||||
const count = sqliteFor(db)
|
||||
.prepare('SELECT COUNT(*) AS count FROM mutation_receipts')
|
||||
.get() as { count: number }
|
||||
expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS)
|
||||
expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined()
|
||||
expect(db.getMutationReceipt('caller', 'remote_pruned')).toMatchObject({ state: 'pending' })
|
||||
expect(db.getRemoteDispatchAttachment('ctx_remote_pruned')).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a remote attachment when pending receipts fill the ledger', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending')
|
||||
|
||||
expect(() =>
|
||||
db!.createRemoteDispatchAttachment({
|
||||
dispatchId: 'ctx_remote_overflow',
|
||||
taskId: 'task_remote_overflow',
|
||||
homePeerFingerprint: 'caller',
|
||||
protocolVersion: 1,
|
||||
runtimeEpoch: 'worker_epoch',
|
||||
mutationReceipt: {
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'remote_overflow',
|
||||
method: 'orchestration.federationAttachStart',
|
||||
payloadHash: 'hash_remote_overflow'
|
||||
}
|
||||
})
|
||||
).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' }))
|
||||
expect(db.getMutationReceipt('caller', 'request_00001')).toMatchObject({ state: 'pending' })
|
||||
expect(db.getMutationReceipt('caller', 'remote_overflow')).toBeUndefined()
|
||||
expect(db.getRemoteDispatchAttachment('ctx_remote_overflow')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('guards atomic worker acceptance without changing task state', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const task = db.createTask({ spec: 'capacity check' })
|
||||
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending')
|
||||
|
||||
expect(() =>
|
||||
db!.createStartingWorkerDispatch({
|
||||
taskId: task.id,
|
||||
startOptions: {},
|
||||
mutationReceipt: {
|
||||
callerFingerprint: 'caller',
|
||||
requestId: 'worker_overflow',
|
||||
method: 'orchestration.workerStart',
|
||||
payloadHash: 'hash_worker_overflow'
|
||||
}
|
||||
})
|
||||
).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' }))
|
||||
expect(db.getTask(task.id)).toMatchObject({ status: 'ready' })
|
||||
expect(db.getMutationReceipt('caller', 'worker_overflow')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OrchestrationDb Run pagination', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('returns stable bounded pages without skipping Runs sharing a timestamp', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const createdIds = Array.from({ length: 5 }, (_, index) => `run_page_${index}`)
|
||||
const insertRun = sqliteFor(db).prepare(
|
||||
`INSERT INTO runs (
|
||||
id, objective, coordinator_handle, coordinator_pane_key,
|
||||
consumer_generation, legacy, created_at
|
||||
) VALUES (?, ?, ?, ?, 1, 0, '2025-01-01 00:00:00')`
|
||||
)
|
||||
for (const [index, id] of createdIds.entries()) {
|
||||
insertRun.run(
|
||||
id,
|
||||
`Run ${index}`,
|
||||
`term_coord_${index}`,
|
||||
`tab_coord_${index}:11111111-1111-4111-8111-111111111111`
|
||||
)
|
||||
}
|
||||
const seen: string[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
do {
|
||||
const page = db.listRuns({ limit: 2, cursor })
|
||||
expect(page.runs.length).toBeLessThanOrEqual(2)
|
||||
expect(page.runs.every((run) => !seen.includes(run.id))).toBe(true)
|
||||
seen.push(...page.runs.map((run) => run.id))
|
||||
cursor = page.nextCursor ?? undefined
|
||||
} while (cursor)
|
||||
|
||||
expect(seen).toEqual(expect.arrayContaining(createdIds))
|
||||
expect(new Set(seen).size).toBe(createdIds.length + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OrchestrationDb dispatch assignee index migration', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('migrates a populated v21 database idempotently', () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-dispatch-index-migration-'))
|
||||
const dbPath = join(tempDir, 'orchestration.db')
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const task = db.createTask({ spec: 'indexed lookup' })
|
||||
const dispatch = db.createDispatchContext(task.id, 'term_worker')
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const oldDb = new Database(dbPath)
|
||||
oldDb.exec('DROP INDEX IF EXISTS idx_dispatch_assignee_handle')
|
||||
oldDb.pragma('user_version = 21')
|
||||
oldDb.close()
|
||||
|
||||
db = new OrchestrationDb(dbPath)
|
||||
const sqlite = sqliteFor(db)
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(22)
|
||||
expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' })
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?")
|
||||
.get('idx_dispatch_assignee_handle')
|
||||
).toBeDefined()
|
||||
const plan = sqlite
|
||||
.prepare(
|
||||
`EXPLAIN QUERY PLAN
|
||||
SELECT * FROM dispatch_contexts
|
||||
WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1`
|
||||
)
|
||||
.all('term_worker') as { detail: string }[]
|
||||
expect(plan.map((row) => row.detail).join('\n')).toContain(
|
||||
'USING INDEX idx_dispatch_assignee_handle'
|
||||
)
|
||||
|
||||
db.close()
|
||||
db = new OrchestrationDb(dbPath)
|
||||
expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(22)
|
||||
expect(db.getDispatchContextById(dispatch.id)).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -151,7 +151,7 @@ describe('OrchestrationDb legacy contract storage', () => {
|
||||
|
||||
expect(db.getLegacyAdoption()).toBeUndefined()
|
||||
expect(db.listLegacyCompatibilityPrincipals(LEGACY_RUN_ID)).toEqual([])
|
||||
expect(db.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
|
||||
expect(db.listRuns().runs).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
|
||||
})
|
||||
|
||||
it('keeps current Delivery disjoint from adopted direct and audit-only mail', () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('OrchestrationDb reset scopes', () => {
|
||||
|
||||
db!.resetAll()
|
||||
|
||||
expect(db!.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
|
||||
expect(db!.listRuns().runs).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
|
||||
expect(db!.getTask(state.task.id)).toBeUndefined()
|
||||
expect(db!.getWorkerDispatch(state.started.dispatch.id)).toBeUndefined()
|
||||
expect(db!.getFederatedDispatch(state.started.dispatch.id)).toBeUndefined()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import { OrchestrationDb } from './db'
|
||||
import { ORCHESTRATION_RUN_METHODS } from '../rpc/methods/orchestration-runs'
|
||||
|
||||
function sqliteFor(db: OrchestrationDb): Database.Database {
|
||||
return (db as unknown as { db: Database.Database }).db
|
||||
}
|
||||
|
||||
describe('orchestration Run list compatibility', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('rejects malformed pagination cursors', () => {
|
||||
const method = ORCHESTRATION_RUN_METHODS.find(
|
||||
(candidate) => candidate.name === 'orchestration.runList'
|
||||
)!
|
||||
|
||||
expect(() => method.params!.parse({ cursor: {} })).toThrow()
|
||||
expect(() => method.params!.parse({ cursor: '' })).toThrow()
|
||||
})
|
||||
|
||||
it('preserves the unpaginated RPC result for an old client request', async () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const runCount = 101
|
||||
sqliteFor(db)
|
||||
.prepare(
|
||||
`WITH RECURSIVE run_numbers(value) AS (
|
||||
VALUES (1)
|
||||
UNION ALL
|
||||
SELECT value + 1 FROM run_numbers WHERE value < ?
|
||||
)
|
||||
INSERT INTO runs (id, objective)
|
||||
SELECT printf('run_compat_%03d', value), printf('Run %03d', value)
|
||||
FROM run_numbers`
|
||||
)
|
||||
.run(runCount)
|
||||
const method = ORCHESTRATION_RUN_METHODS.find(
|
||||
(candidate) => candidate.name === 'orchestration.runList'
|
||||
)!
|
||||
const params = method.params!.parse({})
|
||||
const result = (await method.handler(params, {
|
||||
runtime: { getOrchestrationDb: () => db }
|
||||
} as never)) as { runs: unknown[] }
|
||||
|
||||
expect(result.runs).toHaveLength(runCount + 1)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,29 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as SecureFileModule from '../../../shared/secure-file'
|
||||
import { RelayRevokeOutbox } from './relay-revoke-outbox'
|
||||
|
||||
const secureFileMocks = vi.hoisted(() => ({ failWrites: false }))
|
||||
|
||||
vi.mock('../../../shared/secure-file', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof SecureFileModule>()
|
||||
return {
|
||||
...actual,
|
||||
writeSecureJsonFile: (targetPath: string, value: unknown) => {
|
||||
if (secureFileMocks.failWrites) {
|
||||
throw new Error('disk full')
|
||||
}
|
||||
actual.writeSecureJsonFile(targetPath, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('RelayRevokeOutbox', () => {
|
||||
const paths: string[] = []
|
||||
afterEach(() => {
|
||||
secureFileMocks.failWrites = false
|
||||
for (const path of paths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
@@ -29,4 +46,40 @@ describe('RelayRevokeOutbox', () => {
|
||||
new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('does not retain an enqueue that failed to reach disk', () => {
|
||||
const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-'))
|
||||
paths.push(path)
|
||||
const binding = {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId: 'device-1',
|
||||
ownerIdentityKey: 'user-1\0profile-1\0org-1'
|
||||
}
|
||||
const outbox = new RelayRevokeOutbox(path)
|
||||
secureFileMocks.failWrites = true
|
||||
expect(() => outbox.enqueue(binding)).toThrow('disk full')
|
||||
|
||||
secureFileMocks.failWrites = false
|
||||
const persisted = outbox.enqueue(binding)
|
||||
expect(
|
||||
new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId)
|
||||
).toEqual([persisted])
|
||||
})
|
||||
|
||||
it('does not remove an item in memory when the durable removal fails', () => {
|
||||
const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-'))
|
||||
paths.push(path)
|
||||
const binding = {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId: 'device-1',
|
||||
ownerIdentityKey: 'user-1\0profile-1\0org-1'
|
||||
}
|
||||
const outbox = new RelayRevokeOutbox(path)
|
||||
const item = outbox.enqueue(binding)
|
||||
secureFileMocks.failWrites = true
|
||||
expect(() => outbox.remove(item.reqId)).toThrow('disk full')
|
||||
|
||||
secureFileMocks.failWrites = false
|
||||
expect(outbox.pendingFor(binding.ownerIdentityKey, binding.relayHostId)).toEqual([item])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,8 +54,9 @@ export class RelayRevokeOutbox {
|
||||
return existing
|
||||
}
|
||||
const item = { ...binding, reqId: randomUUID(), createdAt: Date.now() }
|
||||
this.items.push(item)
|
||||
this.save()
|
||||
const next = [...this.items, item]
|
||||
this.save(next)
|
||||
this.items = next
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -70,8 +71,8 @@ export class RelayRevokeOutbox {
|
||||
if (next.length === this.items.length) {
|
||||
return
|
||||
}
|
||||
this.save(next)
|
||||
this.items = next
|
||||
this.save()
|
||||
}
|
||||
|
||||
private load(): RelayRevokeOutboxItem[] {
|
||||
@@ -87,7 +88,7 @@ export class RelayRevokeOutbox {
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
writeSecureJsonFile(this.path, this.items)
|
||||
private save(items: readonly RelayRevokeOutboxItem[]): void {
|
||||
writeSecureJsonFile(this.path, items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +332,14 @@ describe('remote runtime request connection integration', () => {
|
||||
const worktreeId = 'repo-1::C:\\repo\\feature'
|
||||
const ptyId = `${worktreeId}@@pty-1`
|
||||
let sleepSnapshot: RuntimeClientEvent[] = []
|
||||
const launchDraftResolutionSnapshot: RuntimeClientEvent[] = [
|
||||
{
|
||||
type: 'nativeChatLaunchDraftResolved',
|
||||
tabId: 'tab-1',
|
||||
text: 'seed',
|
||||
createdAt: 7
|
||||
}
|
||||
]
|
||||
const emit = (event: RuntimeClientEvent): void => {
|
||||
for (const listener of clientEventListeners) {
|
||||
listener(event)
|
||||
@@ -362,6 +370,7 @@ describe('remote runtime request connection integration', () => {
|
||||
return () => clientEventListeners.delete(listener)
|
||||
},
|
||||
getTerminalSleepClientEventSnapshot: () => sleepSnapshot,
|
||||
getNativeChatLaunchDraftResolutionClientEventSnapshot: () => launchDraftResolutionSnapshot,
|
||||
sleepTerminalsForWorktree: async () => {
|
||||
emit({
|
||||
type: 'worktreeTerminalSleepState',
|
||||
@@ -440,6 +449,9 @@ describe('remote runtime request connection integration', () => {
|
||||
await waitFor(() =>
|
||||
clientEvents.every((events) => events.some((e) => e.type === 'ready'))
|
||||
)
|
||||
for (const events of clientEvents) {
|
||||
expect(events).toContainEqual(launchDraftResolutionSnapshot[0])
|
||||
}
|
||||
await expect(
|
||||
requester.request(
|
||||
'terminal.sleep',
|
||||
@@ -487,6 +499,7 @@ describe('remote runtime request connection integration', () => {
|
||||
.filter((event) => event.type === 'worktreeTerminalSleepState')
|
||||
.map((event) => event.phase)
|
||||
).toEqual(['committed'])
|
||||
expect(reconnectedEvents).toContainEqual(launchDraftResolutionSnapshot[0])
|
||||
|
||||
sleepSnapshot = []
|
||||
emit({
|
||||
|
||||
@@ -88,6 +88,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
'agent_unconfigured',
|
||||
'terminal_worktree_mismatch',
|
||||
'request_mismatch',
|
||||
'mutation_ledger_full',
|
||||
'legacy_read_only',
|
||||
'orchestration_migration_required',
|
||||
'operation_unknown',
|
||||
|
||||
@@ -38,6 +38,10 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [
|
||||
for (const event of runtime.getTerminalSleepClientEventSnapshot?.() ?? []) {
|
||||
emit(event)
|
||||
}
|
||||
for (const event of runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot?.() ??
|
||||
[]) {
|
||||
emit(event)
|
||||
}
|
||||
const sshStates = listRegisteredSshTargets().flatMap((target) => {
|
||||
const state = getPublicSshState(getRegisteredSshState(target.id) ?? null)
|
||||
return state ? [{ targetId: target.id, state }] : []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalBoolean, OptionalString, requiredString } from '../schemas'
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../shared/orchestration-run-pagination'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
|
||||
@@ -16,7 +17,10 @@ const RunUseParams = z.object({
|
||||
})
|
||||
|
||||
const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') })
|
||||
const RunListParams = z.object({})
|
||||
const RunListParams = z.object({
|
||||
limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(),
|
||||
cursor: z.string().min(1).optional()
|
||||
})
|
||||
const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString })
|
||||
|
||||
function requireCallerPane(runtime: OrcaRuntimeService, handle: string): string {
|
||||
@@ -104,7 +108,7 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.runList',
|
||||
params: RunListParams,
|
||||
handler: (_params, { runtime }) => ({ runs: runtime.getOrchestrationDb().listRuns() })
|
||||
handler: (params, { runtime }) => runtime.getOrchestrationDb().listRuns(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'orchestration.runShow',
|
||||
|
||||
@@ -60,7 +60,10 @@ export async function createExistingWorktreeWorkerTerminal(args: {
|
||||
}): Promise<{ handle: string; warning?: string }> {
|
||||
const terminal = await args.runtime.createTerminal(`id:${args.worktreeId}`, {
|
||||
command: args.agent,
|
||||
title: `worker-${args.taskId}`
|
||||
title: `worker-${args.taskId}`,
|
||||
// Why: dispatching a worker is background work; it must not pull the sidebar
|
||||
// to the worker's workspace while the user is reading somewhere else.
|
||||
surfaceOwner: false
|
||||
})
|
||||
args.effects.push({
|
||||
kind: 'terminal',
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('orchestration RPC methods', () => {
|
||||
await expect(
|
||||
call('orchestration.runCreate', { objective: 'No pane', from: 'term_stale' })
|
||||
).rejects.toMatchObject({ code: 'stable_pane_required' })
|
||||
expect(db.listRuns().filter((run) => run.legacy === 0)).toHaveLength(0)
|
||||
expect(db.listRuns().runs.filter((run) => run.legacy === 0)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rebinds explicitly, lists Runs, and keeps the legacy Run inspect-only', async () => {
|
||||
@@ -2176,9 +2176,12 @@ describe('orchestration RPC methods', () => {
|
||||
)
|
||||
expect(db.getTask(task.id)?.status).toBe('dispatched')
|
||||
expect(db.getWorkerDispatch(result.dispatchId)?.state).toBe('ready')
|
||||
// Why: dispatching a worker is background work — surfaceOwner:false adopts
|
||||
// the tab without scrolling the sidebar to the worker's workspace.
|
||||
expect(runtime.createTerminal).toHaveBeenCalledWith('id:repo::worktree', {
|
||||
command: 'codex',
|
||||
title: `worker-${task.id}`
|
||||
title: `worker-${task.id}`,
|
||||
surfaceOwner: false
|
||||
})
|
||||
expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith(
|
||||
'term_worker',
|
||||
@@ -2277,7 +2280,9 @@ describe('orchestration RPC methods', () => {
|
||||
)
|
||||
expect(runtime.createTerminal).toHaveBeenCalledWith(
|
||||
'id:repo::other',
|
||||
expect.objectContaining({ command: 'codex' })
|
||||
// Why: starting a worker in an existing worktree must not pull the sidebar
|
||||
// away from whatever the user is looking at.
|
||||
expect.objectContaining({ command: 'codex', surfaceOwner: false })
|
||||
)
|
||||
expect(createWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -863,6 +863,12 @@ const TerminalSend = TerminalHandle.extend({
|
||||
text: OptionalString,
|
||||
enter: z.unknown().optional(),
|
||||
interrupt: z.unknown().optional(),
|
||||
resolvedLaunchDraft: z
|
||||
.object({
|
||||
text: z.string(),
|
||||
createdAt: z.number().finite()
|
||||
})
|
||||
.optional(),
|
||||
requireAgentStatus: z.enum(['sendable']).optional(),
|
||||
// Why: terminal-generated replies are valid input but must not transfer the shared terminal floor.
|
||||
inputKind: z.enum(['query-reply']).optional(),
|
||||
@@ -1183,6 +1189,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
params: TerminalSend,
|
||||
handler: async (params, { runtime, clientId }) => {
|
||||
await assertTerminalSendTextWithinLimit(params.text)
|
||||
await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text)
|
||||
const queryReplyClientId = clientId ?? params.client?.id
|
||||
if (
|
||||
params.inputKind === 'query-reply' &&
|
||||
@@ -1361,6 +1368,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
if (result.accepted !== true) {
|
||||
mobileFloorClaim.current?.rollback()
|
||||
}
|
||||
if (
|
||||
result.accepted === true &&
|
||||
params.enter === true &&
|
||||
params.client?.type === 'mobile' &&
|
||||
params.resolvedLaunchDraft
|
||||
) {
|
||||
runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft)
|
||||
}
|
||||
// Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver.
|
||||
return { send: result }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { WORKTREE_METHODS } from './worktree'
|
||||
|
||||
describe('worktree missing-terminal teardown RPC', () => {
|
||||
it('routes the connection-scoped request through the runtime owner', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
teardownMissingManagedWorktreeTerminals: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ stoppedWorktreeIds: ['repo-1::/workspace/deleted'] })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch({
|
||||
id: 'request-1',
|
||||
authToken: 'token',
|
||||
method: 'worktree.teardownMissingTerminals',
|
||||
params: {
|
||||
repo: 'repo-1',
|
||||
worktreeIds: ['repo-1::/workspace/deleted'],
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(runtime.teardownMissingManagedWorktreeTerminals).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
['repo-1::/workspace/deleted'],
|
||||
'ssh-1'
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,11 @@ export const WorktreeDetectedListParams = z.object({
|
||||
.pipe(z.string().min(1, 'Missing repo selector'))
|
||||
})
|
||||
|
||||
export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({
|
||||
worktreeIds: z.array(z.string().min(1)).max(10_000),
|
||||
connectionId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
export const WorktreePsParams = z.object({
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
WorktreeResolvePrBase,
|
||||
WorktreeSelector,
|
||||
WorktreeSet,
|
||||
WorktreeSortOrder
|
||||
WorktreeSortOrder,
|
||||
WorktreeTeardownMissingTerminalsParams
|
||||
} from './worktree-schemas'
|
||||
|
||||
export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
@@ -38,6 +39,16 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
params: WorktreeDetectedListParams,
|
||||
handler: async (params, { runtime }) => runtime.listDetectedManagedWorktrees(params.repo)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.teardownMissingTerminals',
|
||||
params: WorktreeTeardownMissingTerminalsParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.teardownMissingManagedWorktreeTerminals(
|
||||
params.repo,
|
||||
params.worktreeIds,
|
||||
params.connectionId
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.lineageList',
|
||||
params: null,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { RpcRequest } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
|
||||
const RESOLUTION = { text: 'seed', createdAt: 1 }
|
||||
|
||||
function makeRequest(params: unknown): RpcRequest {
|
||||
return { id: 'request', authToken: 'token', method: 'terminal.send', params }
|
||||
}
|
||||
|
||||
function makeRuntime(accepted: boolean): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'runtime',
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
beginMobileInputFloor: vi.fn().mockReturnValue({ commit: vi.fn(), rollback: vi.fn() }),
|
||||
sendTerminal: vi.fn().mockResolvedValue({
|
||||
handle: 'terminal-1',
|
||||
accepted,
|
||||
bytesWritten: accepted ? 1 : 0
|
||||
}),
|
||||
notifyNativeChatLaunchDraftResolved: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
async function send(
|
||||
runtime: OrcaRuntimeService,
|
||||
options: { enter: boolean; clientType: 'mobile' | 'desktop' }
|
||||
): Promise<void> {
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
await dispatcher.dispatch(
|
||||
makeRequest({
|
||||
terminal: 'terminal-1',
|
||||
text: 'hello',
|
||||
enter: options.enter,
|
||||
resolvedLaunchDraft: RESOLUTION,
|
||||
client: { id: 'client-1', type: options.clientType }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('terminal.send launch-draft resolution', () => {
|
||||
it('notifies after an accepted mobile submit', async () => {
|
||||
const runtime = makeRuntime(true)
|
||||
|
||||
await send(runtime, { enter: true, clientType: 'mobile' })
|
||||
|
||||
expect(runtime.notifyNativeChatLaunchDraftResolved).toHaveBeenCalledWith(
|
||||
'terminal-1',
|
||||
RESOLUTION
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['rejected submit', false, true, 'mobile'],
|
||||
['clear-only write', true, false, 'mobile'],
|
||||
['desktop submit', true, true, 'desktop']
|
||||
] as const)('does not notify after a %s', async (_case, accepted, enter, clientType) => {
|
||||
const runtime = makeRuntime(accepted)
|
||||
|
||||
await send(runtime, { enter, clientType })
|
||||
|
||||
expect(runtime.notifyNativeChatLaunchDraftResolved).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -746,7 +746,74 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to a valid direct-only GUI offer when relay invite minting fails', async () => {
|
||||
it('queues the old Relay binding when a stable provider changes accounts', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
let relayHostId = 'AbCdEf0123_-xyZ9'
|
||||
let ownerIdentityKey = 'user-a\0profile-a\0org'
|
||||
const onDeviceRevokeQueued = vi.fn()
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: async (relayDeviceId) => ({
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId,
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2
|
||||
},
|
||||
binding: {
|
||||
relayHostId,
|
||||
relayDeviceId,
|
||||
ownerIdentityKey
|
||||
}
|
||||
}),
|
||||
onDeviceRevokeQueued,
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const first = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
expect(first.available).toBe(true)
|
||||
if (!first.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
const firstBinding = server.getDeviceRegistry()?.getDevice(first.deviceId)?.relayBinding
|
||||
expect(firstBinding).toBeTruthy()
|
||||
if (!firstBinding) {
|
||||
throw new Error('Relay binding unavailable')
|
||||
}
|
||||
relayHostId = 'ZyXwVu9876_-abcD'
|
||||
ownerIdentityKey = 'user-b\0profile-b\0org'
|
||||
|
||||
const second = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
expect(second.available).toBe(true)
|
||||
if (!second.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
expect(second.deviceId).toBe(first.deviceId)
|
||||
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
||||
expect(onDeviceRevokeQueued).toHaveBeenCalledWith(expect.objectContaining(firstBinding))
|
||||
expect(server.getDeviceRegistry()?.getDevice(second.deviceId)?.relayBinding).toEqual({
|
||||
relayHostId,
|
||||
relayDeviceId: second.deviceId,
|
||||
ownerIdentityKey
|
||||
})
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a silent LAN QR when relay invite minting fails under Anywhere', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
@@ -764,18 +831,576 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await server.start()
|
||||
try {
|
||||
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
expect(offer.available).toBe(true)
|
||||
if (!offer.available) {
|
||||
// Why: Anywhere must not ship a scannable local-only code under the Relay label.
|
||||
expect(offer.available).toBe(false)
|
||||
if (offer.available) {
|
||||
throw new Error('expected relay mint failure')
|
||||
}
|
||||
expect(offer.reason).toBe('relay_mint_failed')
|
||||
expect(offer.relayFailure).toMatchObject({
|
||||
code: 'relay_mint_failed',
|
||||
stage: 'create_pairing_relay'
|
||||
})
|
||||
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a missing Relay provider without creating a fallback QR', 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 {
|
||||
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
expect(offer).toMatchObject({
|
||||
available: false,
|
||||
reason: 'relay_mint_failed',
|
||||
relayFailure: {
|
||||
code: 'relay_provider_unavailable',
|
||||
stage: 'provider_missing',
|
||||
message: 'Orca Relay is not available on this desktop'
|
||||
}
|
||||
})
|
||||
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves an existing Relay QR when a same-mode remint fails', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
const createPairingRelay = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async (relayDeviceId: string) => ({
|
||||
relay: {
|
||||
v: 1 as const,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2 as const
|
||||
},
|
||||
binding: {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}))
|
||||
.mockRejectedValueOnce(new Error('relay offline'))
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay,
|
||||
onDeviceRevokeQueued: vi.fn(),
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const first = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
expect(first.available).toBe(true)
|
||||
if (!first.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
expect(parsePairingCode(offer.pairingUrl)).toMatchObject({
|
||||
endpoint: offer.endpoint,
|
||||
scope: 'mobile'
|
||||
const second = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
expect(second.available).toBe(false)
|
||||
expect(server.getDeviceRegistry()?.getDevice(first.deviceId)?.relayBinding).toBeTruthy()
|
||||
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')?.deviceId).toBe(first.deviceId)
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('coalesces concurrent mobile Relay mints for the shared pending credential', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
let resolveFirst: (() => void) | undefined
|
||||
const firstMint = new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const createPairingRelay = vi.fn(async (relayDeviceId: string) => {
|
||||
await firstMint
|
||||
return {
|
||||
relay: {
|
||||
v: 1 as const,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2 as const
|
||||
},
|
||||
binding: {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}
|
||||
})
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay,
|
||||
onDeviceRevokeQueued: vi.fn(),
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const first = server.createMobilePairingOffer({ address: '100.64.1.20', rotate: true })
|
||||
const second = server.createMobilePairingOffer({ address: '100.64.1.20', rotate: true })
|
||||
await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledTimes(1))
|
||||
resolveFirst?.()
|
||||
const [firstOffer, secondOffer] = await Promise.all([first, second])
|
||||
expect(firstOffer.available).toBe(true)
|
||||
expect(secondOffer.available).toBe(true)
|
||||
if (!firstOffer.available || !secondOffer.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
expect(secondOffer.deviceId).toBe(firstOffer.deviceId)
|
||||
expect(secondOffer.pairingUrl).toBe(firstOffer.pairingUrl)
|
||||
expect(createPairingRelay).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('supersedes an older concurrent Relay rotation for a different address', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
let resolveFirst: (() => void) | undefined
|
||||
const firstMint = new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const createPairingRelay = vi.fn(async (relayDeviceId: string) => {
|
||||
if (createPairingRelay.mock.calls.length === 1) {
|
||||
await firstMint
|
||||
}
|
||||
return {
|
||||
relay: {
|
||||
v: 1 as const,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2 as const
|
||||
},
|
||||
binding: {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}
|
||||
})
|
||||
const onDeviceRevokeQueued = vi.fn()
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay,
|
||||
onDeviceRevokeQueued,
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const first = server.createMobilePairingOffer({
|
||||
address: '100.64.1.20',
|
||||
rotate: true
|
||||
})
|
||||
expect(parsePairingCode(offer.pairingUrl)).not.toHaveProperty('relay')
|
||||
// Why: the result reports what the offer actually encodes so the UI can
|
||||
// flag the degraded mint instead of labeling it as Relay.
|
||||
expect(offer.connectionMode).toBe('local-only')
|
||||
await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledOnce())
|
||||
const second = server.createMobilePairingOffer({
|
||||
address: '100.64.1.21',
|
||||
rotate: true
|
||||
})
|
||||
resolveFirst?.()
|
||||
await expect(first).resolves.toMatchObject({
|
||||
available: false,
|
||||
relayFailure: { code: 'relay_request_superseded' }
|
||||
})
|
||||
const secondOffer = await second
|
||||
expect(secondOffer.available).toBe(true)
|
||||
if (!secondOffer.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
expect(secondOffer.endpoint).toContain('100.64.1.21')
|
||||
expect(createPairingRelay).toHaveBeenCalledTimes(2)
|
||||
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('supersedes an older concurrent Relay mint for a different address without rotate', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
let resolveFirst: (() => void) | undefined
|
||||
const firstMint = new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const createPairingRelay = vi.fn(async (relayDeviceId: string) => {
|
||||
if (createPairingRelay.mock.calls.length === 1) {
|
||||
await firstMint
|
||||
}
|
||||
return {
|
||||
relay: {
|
||||
v: 1 as const,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2 as const
|
||||
},
|
||||
binding: {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}
|
||||
})
|
||||
const onDeviceRevokeQueued = vi.fn()
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay,
|
||||
onDeviceRevokeQueued,
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const first = server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledOnce())
|
||||
const second = server.createMobilePairingOffer({ address: '100.64.1.21' })
|
||||
resolveFirst?.()
|
||||
await expect(first).resolves.toMatchObject({
|
||||
available: false,
|
||||
relayFailure: { code: 'relay_request_superseded' }
|
||||
})
|
||||
const secondOffer = await second
|
||||
expect(secondOffer.available).toBe(true)
|
||||
if (!secondOffer.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
expect(secondOffer.endpoint).toContain('100.64.1.21')
|
||||
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets LAN supersede a pending Relay mint without waiting for it', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
let resolveRelay: (() => void) | undefined
|
||||
const relayGate = new Promise<void>((resolve) => {
|
||||
resolveRelay = resolve
|
||||
})
|
||||
const onDeviceRevokeQueued = vi.fn()
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: async (relayDeviceId) => {
|
||||
await relayGate
|
||||
return {
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2
|
||||
},
|
||||
binding: {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}
|
||||
},
|
||||
onDeviceRevokeQueued,
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const relayOffer = server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
await vi.waitFor(() =>
|
||||
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).not.toBeNull()
|
||||
)
|
||||
const localOffer = await server.createMobilePairingOffer({
|
||||
address: '100.64.1.20',
|
||||
connectionMode: 'local-only'
|
||||
})
|
||||
expect(localOffer.available).toBe(true)
|
||||
if (!localOffer.available) {
|
||||
throw new Error('LAN pairing unavailable')
|
||||
}
|
||||
expect(localOffer.connectionMode).toBe('local-only')
|
||||
resolveRelay?.()
|
||||
const staleRelayOffer = await relayOffer
|
||||
expect(staleRelayOffer.available).toBe(false)
|
||||
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')?.deviceId).toBe(
|
||||
localOffer.deviceId
|
||||
)
|
||||
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('revokes a Relay invite when binding persistence throws', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
const onDeviceRevokeQueued = vi.fn()
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: async (relayDeviceId) => ({
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2
|
||||
},
|
||||
binding: {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}),
|
||||
onDeviceRevokeQueued,
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const registry = server.getDeviceRegistry()
|
||||
if (!registry) {
|
||||
throw new Error('Device registry unavailable')
|
||||
}
|
||||
vi.spyOn(registry, 'setRelayBinding').mockImplementation(() => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
expect(offer.available).toBe(false)
|
||||
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
||||
expect(registry.getPendingDevice('mobile')).toBeNull()
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('revokes a Relay result from a provider replaced during minting', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
let resolveRelay: (() => void) | undefined
|
||||
const relayGate = new Promise<void>((resolve) => {
|
||||
resolveRelay = resolve
|
||||
})
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: async (relayDeviceId) => {
|
||||
await relayGate
|
||||
return {
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2
|
||||
},
|
||||
binding: {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}
|
||||
},
|
||||
onDeviceRevokeQueued: vi.fn(),
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const offerPromise = server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
await vi.waitFor(() =>
|
||||
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).not.toBeNull()
|
||||
)
|
||||
const onDeviceRevokeQueued = vi.fn()
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: vi.fn(),
|
||||
onDeviceRevokeQueued,
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
resolveRelay?.()
|
||||
await expect(offerPromise).resolves.toMatchObject({ available: false })
|
||||
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
||||
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains a minted Relay binding on the device when cleanup cannot be queued', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
let resolveRelay: (() => void) | undefined
|
||||
const relayGate = new Promise<void>((resolve) => {
|
||||
resolveRelay = resolve
|
||||
})
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: async (relayDeviceId) => {
|
||||
await relayGate
|
||||
return {
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2
|
||||
},
|
||||
binding: {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}
|
||||
},
|
||||
onDeviceRevokeQueued: vi.fn(),
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const registry = server.getDeviceRegistry()
|
||||
if (!registry) {
|
||||
throw new Error('Device registry unavailable')
|
||||
}
|
||||
const offerPromise = server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
await vi.waitFor(() => expect(registry.getPendingDevice('mobile')).not.toBeNull())
|
||||
const deviceId = registry.getPendingDevice('mobile')?.deviceId
|
||||
vi.spyOn(server.getRelayRevokeOutbox(), 'enqueue').mockImplementation(() => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
// Why: swapping the provider supersedes the in-flight mint.
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: vi.fn(),
|
||||
onDeviceRevokeQueued: vi.fn(),
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
resolveRelay?.()
|
||||
await expect(offerPromise).resolves.toMatchObject({ available: false })
|
||||
expect(registry.getDevice(deviceId ?? '')?.relayBinding).toMatchObject({
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId: deviceId
|
||||
})
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('queues cloud cleanup when a minted Relay binding cannot be persisted', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
const onDeviceRevokeQueued = vi.fn()
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: async () => ({
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2
|
||||
},
|
||||
binding: {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId: 'wrong-device',
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}),
|
||||
onDeviceRevokeQueued,
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
||||
expect(offer.available).toBe(false)
|
||||
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
||||
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
@@ -833,6 +1458,28 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
server.setMobileRelayPairingProvider({
|
||||
createPairingRelay: async (relayDeviceId) => ({
|
||||
relay: {
|
||||
v: 1,
|
||||
directorUrl: 'https://relay.example.com',
|
||||
cellUrl: 'https://cell.example.com',
|
||||
assignmentEpoch: 7,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
inviteToken: 'A'.repeat(43),
|
||||
inviteExpiresAt: Date.now() + 60_000,
|
||||
e2eeFraming: 2
|
||||
},
|
||||
binding: {
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId,
|
||||
ownerIdentityKey: 'user\0profile\0org'
|
||||
}
|
||||
}),
|
||||
onDeviceRevokeQueued: vi.fn(),
|
||||
getEndpoints: vi.fn(),
|
||||
provisionRelay: vi.fn()
|
||||
})
|
||||
|
||||
await server.start()
|
||||
try {
|
||||
@@ -843,6 +1490,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
if (!offer.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
expect(offer.connectionMode).toBe('automatic')
|
||||
expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe(
|
||||
'automatic'
|
||||
)
|
||||
|
||||
+249
-38
@@ -29,6 +29,10 @@ import {
|
||||
} from './rpc/mobile-socket-wiring'
|
||||
import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer'
|
||||
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
|
||||
import {
|
||||
mobileRelayMintFailureFromUnknown,
|
||||
type MobileRelayMintFailure
|
||||
} from '../../shared/mobile-relay-mint-failure'
|
||||
import {
|
||||
RelayRevokeOutbox,
|
||||
type RelayDeviceBinding,
|
||||
@@ -71,13 +75,28 @@ export type PairingOfferUnavailableReason =
|
||||
| 'device_registry_unavailable'
|
||||
| 'e2ee_key_unavailable'
|
||||
| 'invalid_advertised_endpoint'
|
||||
| 'relay_mint_failed'
|
||||
|
||||
export type PairingOfferUnavailable = {
|
||||
available: false
|
||||
reason: PairingOfferUnavailableReason
|
||||
guidance: string
|
||||
/** Present when an Anywhere mint refused to silently fall back to LAN-only. */
|
||||
relayFailure?: MobileRelayMintFailure
|
||||
}
|
||||
|
||||
type MobilePairingOfferAvailable = {
|
||||
available: true
|
||||
pairingUrl: string
|
||||
endpoint: string
|
||||
deviceId: string
|
||||
webClientUrl: string | null
|
||||
/** Mode the offer actually encodes. */
|
||||
connectionMode: MobilePairingConnectionMode
|
||||
}
|
||||
|
||||
type MobilePairingOffer = PairingOfferUnavailable | MobilePairingOfferAvailable
|
||||
|
||||
type PairingIdentityInitialization =
|
||||
| { ok: true; deviceRegistry: DeviceRegistry; e2eeKeypair: E2EEKeypair }
|
||||
| { ok: false; failure: PairingOfferUnavailable }
|
||||
@@ -469,6 +488,14 @@ export class OrcaRuntimeRpcServer {
|
||||
private metadataOwnershipWatch: RuntimeMetadataOwnershipWatch | null = null
|
||||
private mobileSocketWiring: MobileSocketWiring | null = null
|
||||
private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null
|
||||
private mobileRelayPairingOfferQueue: Promise<void> = Promise.resolve()
|
||||
private mobileRelayPairingOfferInFlight: {
|
||||
generation: number
|
||||
address: string | null
|
||||
rotate: boolean
|
||||
request: Promise<MobilePairingOffer>
|
||||
} | null = null
|
||||
private mobilePairingOfferGeneration = 0
|
||||
private onUnpairedDeviceAuthFailure: (() => void) | null = null
|
||||
private unpairedDeviceAuthThrottle: UnpairedDeviceAuthThrottle | null = null
|
||||
private readonly binaryStreamHandlers = new Map<
|
||||
@@ -552,7 +579,9 @@ export class OrcaRuntimeRpcServer {
|
||||
current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey)
|
||||
) {
|
||||
// Why: switching the owning account/host must not strand the old cloud credential family, even if that account is offline.
|
||||
this.queueRelayDeviceRevoke(current.relayBinding)
|
||||
if (!this.queueRelayDeviceRevoke(current.relayBinding)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false
|
||||
if (updated) {
|
||||
@@ -576,7 +605,9 @@ export class OrcaRuntimeRpcServer {
|
||||
return false
|
||||
}
|
||||
if (device.relayBinding) {
|
||||
this.queueRelayDeviceRevoke(device.relayBinding)
|
||||
if (!this.queueRelayDeviceRevoke(device.relayBinding)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (!this.deviceRegistry?.removeDevice(deviceId)) {
|
||||
return false
|
||||
@@ -672,18 +703,57 @@ export class OrcaRuntimeRpcServer {
|
||||
connectionMode?: MobilePairingConnectionMode
|
||||
name?: string
|
||||
rotate?: boolean
|
||||
}): Promise<
|
||||
| PairingOfferUnavailable
|
||||
| {
|
||||
available: true
|
||||
pairingUrl: string
|
||||
endpoint: string
|
||||
deviceId: string
|
||||
webClientUrl: string | null
|
||||
/** Mode the offer actually encodes — 'local-only' when an automatic request degraded (Relay couldn't attach). */
|
||||
connectionMode: MobilePairingConnectionMode
|
||||
}): Promise<MobilePairingOffer> {
|
||||
if (args.connectionMode === 'local-only') {
|
||||
this.mobilePairingOfferGeneration += 1
|
||||
return this.createMobilePairingOfferSerial(args, this.mobilePairingOfferGeneration)
|
||||
}
|
||||
const address = args.address ?? null
|
||||
const rotate = args.rotate === true
|
||||
const inFlight = this.mobileRelayPairingOfferInFlight
|
||||
if (
|
||||
inFlight?.generation === this.mobilePairingOfferGeneration &&
|
||||
inFlight.address === address &&
|
||||
(inFlight.rotate || !rotate)
|
||||
) {
|
||||
return inFlight.request
|
||||
}
|
||||
// Why: every request that is not coalesced above supersedes the older one, rotating or not.
|
||||
const generation = ++this.mobilePairingOfferGeneration
|
||||
const request = this.mobileRelayPairingOfferQueue.then(() =>
|
||||
generation === this.mobilePairingOfferGeneration
|
||||
? this.createMobilePairingOfferSerial(args, generation)
|
||||
: this.relayPairingRequestSuperseded()
|
||||
)
|
||||
this.mobileRelayPairingOfferQueue = request.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
this.mobileRelayPairingOfferInFlight = { generation, address, rotate, request }
|
||||
void request.then(
|
||||
() => {
|
||||
if (this.mobileRelayPairingOfferInFlight?.request === request) {
|
||||
this.mobileRelayPairingOfferInFlight = null
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (this.mobileRelayPairingOfferInFlight?.request === request) {
|
||||
this.mobileRelayPairingOfferInFlight = null
|
||||
}
|
||||
}
|
||||
> {
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
private async createMobilePairingOfferSerial(
|
||||
args: {
|
||||
address?: string | null
|
||||
connectionMode?: MobilePairingConnectionMode
|
||||
name?: string
|
||||
rotate?: boolean
|
||||
},
|
||||
generation: number
|
||||
): Promise<MobilePairingOffer> {
|
||||
// Why: the renderer is outside the trust boundary, so only an explicit local-only value may suppress Relay provisioning.
|
||||
const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic'
|
||||
const pending = this.deviceRegistry?.getPendingDevice('mobile')
|
||||
@@ -694,7 +764,12 @@ export class OrcaRuntimeRpcServer {
|
||||
if (args.rotate || switchingPendingMode) {
|
||||
if (pending?.relayBinding) {
|
||||
// Why: record the durable cloud revoke before rotating the local token so an old relay invite can't outlive the QR.
|
||||
this.queueRelayDeviceRevoke(pending.relayBinding)
|
||||
if (!this.queueRelayDeviceRevoke(pending.relayBinding)) {
|
||||
return pairingUnavailable(
|
||||
'device_registry_unavailable',
|
||||
'Could not persist Relay cleanup before rotating the pairing code.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
const direct = this.createPairingOffer({
|
||||
@@ -705,42 +780,178 @@ export class OrcaRuntimeRpcServer {
|
||||
if (!direct.available) {
|
||||
return direct
|
||||
}
|
||||
this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode)
|
||||
if (connectionMode === 'local-only' || !this.mobileRelayPairingProvider) {
|
||||
const createdNewPendingDevice = pending?.deviceId !== direct.deviceId
|
||||
let connectionModeStored = false
|
||||
try {
|
||||
connectionModeStored =
|
||||
this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) ??
|
||||
false
|
||||
} catch (error) {
|
||||
console.error('[runtime] Failed to persist the pairing connection mode:', error)
|
||||
}
|
||||
// Why: the mode is part of the credential — a QR whose policy was never stored must not pair under the default one.
|
||||
if (!connectionModeStored) {
|
||||
if (createdNewPendingDevice) {
|
||||
this.discardPendingMobilePairingDevice(direct.deviceId)
|
||||
}
|
||||
return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE)
|
||||
}
|
||||
// Why: explicit LAN path never needs Relay; mint the direct-only offer as selected.
|
||||
if (connectionMode === 'local-only') {
|
||||
return { ...direct, connectionMode: 'local-only' }
|
||||
}
|
||||
// Why: Anywhere must not silently ship a LAN-only QR under the Relay label.
|
||||
// Fail closed, drop the unused pending credential, and let the UI offer Use LAN.
|
||||
const refuseAutomaticWithoutRelay = (
|
||||
relayFailure: MobileRelayMintFailure
|
||||
): PairingOfferUnavailable => {
|
||||
if (createdNewPendingDevice) {
|
||||
this.discardPendingMobilePairingDevice(direct.deviceId)
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
reason: 'relay_mint_failed',
|
||||
guidance:
|
||||
'Orca Relay could not create a pairing invite. Use LAN (Tailscale or same Wi‑Fi) or retry Relay.',
|
||||
relayFailure
|
||||
}
|
||||
}
|
||||
const relayProvider = this.mobileRelayPairingProvider
|
||||
if (!relayProvider) {
|
||||
return refuseAutomaticWithoutRelay({
|
||||
code: 'relay_provider_unavailable',
|
||||
stage: 'provider_missing',
|
||||
message: 'Orca Relay is not available on this desktop'
|
||||
})
|
||||
}
|
||||
const device = this.deviceRegistry?.getDevice(direct.deviceId)
|
||||
const publicKeyB64 = this.getE2EEPublicKey()
|
||||
if (!device || !publicKeyB64) {
|
||||
return { ...direct, connectionMode: 'local-only' }
|
||||
return refuseAutomaticWithoutRelay({
|
||||
code: 'e2ee_key_unavailable',
|
||||
stage: 'e2ee_missing',
|
||||
message: 'E2EE public key unavailable for Relay pairing'
|
||||
})
|
||||
}
|
||||
let relayPairing: Awaited<ReturnType<MobileRelayPairingProvider['createPairingRelay']>>
|
||||
try {
|
||||
relayPairing = await relayProvider.createPairingRelay(device.deviceId)
|
||||
} catch (error) {
|
||||
// Why: the raw provider error can carry request metadata or credentials — log only the validated code.
|
||||
const relayFailure = mobileRelayMintFailureFromUnknown({
|
||||
stage: 'create_pairing_relay',
|
||||
error,
|
||||
fallbackCode: 'relay_mint_failed',
|
||||
fallbackMessage: 'Relay pairing invite request failed'
|
||||
})
|
||||
console.warn(`[runtime] Failed to create Relay pairing invite: ${relayFailure.code}`)
|
||||
return refuseAutomaticWithoutRelay(relayFailure)
|
||||
}
|
||||
const currentDevice = this.deviceRegistry?.getDevice(device.deviceId)
|
||||
if (
|
||||
generation !== this.mobilePairingOfferGeneration ||
|
||||
relayProvider !== this.mobileRelayPairingProvider ||
|
||||
currentDevice?.token !== device.token ||
|
||||
this.deviceRegistry?.getMobilePairingConnectionMode(device.deviceId) !== 'automatic'
|
||||
) {
|
||||
this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding)
|
||||
if (createdNewPendingDevice) {
|
||||
this.discardPendingMobilePairingDevice(direct.deviceId)
|
||||
}
|
||||
return this.relayPairingRequestSuperseded()
|
||||
}
|
||||
try {
|
||||
const relayPairing = await this.mobileRelayPairingProvider.createPairingRelay(device.deviceId)
|
||||
if (!this.deviceRegistry?.setRelayBinding(device.deviceId, relayPairing.binding)) {
|
||||
return { ...direct, connectionMode: 'local-only' }
|
||||
}
|
||||
this.mobileRelayPairingProvider.onDemandStateChanged?.()
|
||||
return {
|
||||
...direct,
|
||||
connectionMode: 'automatic',
|
||||
pairingUrl: encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint: direct.endpoint,
|
||||
deviceToken: device.token,
|
||||
publicKeyB64,
|
||||
scope: 'mobile',
|
||||
relay: relayPairing.relay
|
||||
if (!this.setMobileRelayBinding(device.deviceId, relayPairing.binding)) {
|
||||
this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding)
|
||||
return refuseAutomaticWithoutRelay({
|
||||
code: 'relay_binding_failed',
|
||||
stage: 'binding_failed',
|
||||
message: 'Could not store Relay binding for the pairing device'
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Why: relay is additive — a transient outage must still yield the valid LAN/Tailscale pairing offer.
|
||||
return { ...direct, connectionMode: 'local-only' }
|
||||
} catch (error) {
|
||||
console.warn('[runtime] Failed to persist Relay pairing binding:', error)
|
||||
this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding)
|
||||
return refuseAutomaticWithoutRelay({
|
||||
code: 'relay_binding_failed',
|
||||
stage: 'binding_failed',
|
||||
message: 'Could not store Relay binding for the pairing device'
|
||||
})
|
||||
}
|
||||
return {
|
||||
...direct,
|
||||
connectionMode: 'automatic',
|
||||
pairingUrl: encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint: direct.endpoint,
|
||||
deviceToken: device.token,
|
||||
publicKeyB64,
|
||||
scope: 'mobile',
|
||||
relay: relayPairing.relay
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private queueRelayDeviceRevoke(binding: RelayDeviceBinding): void {
|
||||
const item = this.relayRevokeOutbox.enqueue(binding)
|
||||
this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item)
|
||||
private relayPairingRequestSuperseded(): PairingOfferUnavailable {
|
||||
return {
|
||||
available: false,
|
||||
reason: 'relay_mint_failed',
|
||||
guidance: 'The Relay pairing request was replaced by a newer connection choice.',
|
||||
relayFailure: {
|
||||
code: 'relay_request_superseded',
|
||||
stage: 'binding_failed',
|
||||
message: 'Relay pairing request superseded'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a never-scanned mobile pending credential after a failed Anywhere mint. */
|
||||
private discardPendingMobilePairingDevice(deviceId: string): void {
|
||||
const device = this.deviceRegistry?.getDevice(deviceId)
|
||||
if (!device || device.scope !== 'mobile' || device.lastSeenAt !== 0) {
|
||||
return
|
||||
}
|
||||
if (device.relayBinding) {
|
||||
if (!this.queueRelayDeviceRevoke(device.relayBinding)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.deviceRegistry?.removeDevice(deviceId)
|
||||
} catch (error) {
|
||||
console.error('[runtime] Failed to drop an unused mobile pairing credential:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: the outbox is the only durable cleanup record for a minted invite. When it can't be
|
||||
* written, keep the binding on the device so cleanup keeps a reference instead of orphaning it.
|
||||
*/
|
||||
private queueOrRetainRelayDeviceRevoke(deviceId: string, binding: RelayDeviceBinding): void {
|
||||
if (this.queueRelayDeviceRevoke(binding)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.deviceRegistry?.setRelayBinding(deviceId, binding)
|
||||
} catch (error) {
|
||||
console.error('[runtime] Failed to retain an unrevoked Relay binding:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean {
|
||||
let item: RelayRevokeOutboxItem
|
||||
try {
|
||||
item = this.relayRevokeOutbox.enqueue(binding)
|
||||
} catch (error) {
|
||||
console.error('[runtime] Failed to persist Relay device cleanup:', error)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item)
|
||||
} catch (error) {
|
||||
console.warn('[runtime] Failed to notify Relay cleanup worker:', error)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private registerBinaryStreamHandler(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
|
||||
|
||||
@@ -20,6 +20,10 @@ describe('isServerDriveListRequest', () => {
|
||||
})
|
||||
|
||||
describe('listWindowsDrives', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const statOnly = (mounted: string[]) => async (p: string) => {
|
||||
if (!mounted.includes(p)) {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
@@ -51,14 +55,62 @@ describe('listWindowsDrives', () => {
|
||||
expect(result.entries.map((e) => e.name)).toEqual(['C:\\'])
|
||||
})
|
||||
|
||||
it.each(['EIO', 'EMFILE'])('surfaces systemic %s failures', async (code) => {
|
||||
it('skips an unreadable drive with an unexpected errno and logs it', async () => {
|
||||
const error = Object.assign(new Error('EIO'), { code: 'EIO' })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const statPath = async (p: string): Promise<Stats> => {
|
||||
if (p === 'D:\\') {
|
||||
throw Object.assign(new Error(code), { code })
|
||||
throw error
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
await expect(listWindowsDrives(statPath)).rejects.toMatchObject({ code })
|
||||
const result = await listWindowsDrives(statPath)
|
||||
|
||||
expect(result.entries).toEqual([])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledWith('[windows-drive-listing] Failed to stat drive', {
|
||||
root: 'D:\\',
|
||||
error
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces programming errors from isDirectory', async () => {
|
||||
const error = new TypeError('programmer bug')
|
||||
const statPath = async (p: string): Promise<Stats> => {
|
||||
if (p === 'D:\\') {
|
||||
return {
|
||||
isDirectory: () => {
|
||||
throw error
|
||||
}
|
||||
} as unknown as Stats
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
await expect(listWindowsDrives(statPath)).rejects.toBe(error)
|
||||
})
|
||||
|
||||
it('returns healthy drives among tolerated and unexpected stat failures', async () => {
|
||||
const error = Object.assign(new Error('EIO'), { code: 'EIO' })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const statPath = async (p: string): Promise<Stats> => {
|
||||
if (p === 'C:\\' || p === 'F:\\') {
|
||||
return { isDirectory: () => true } as Stats
|
||||
}
|
||||
if (p === 'E:\\') {
|
||||
throw error
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
const result = await listWindowsDrives(statPath)
|
||||
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(['C:\\', 'F:\\'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledWith('[windows-drive-listing] Failed to stat drive', {
|
||||
root: 'E:\\',
|
||||
error
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,15 +26,16 @@ export async function listWindowsDrives(
|
||||
const roots = await Promise.all(
|
||||
[...DRIVE_LETTERS].map(async (letter) => {
|
||||
const root = `${letter}:\\`
|
||||
let stats: Stats
|
||||
try {
|
||||
const stats = await statPath(root)
|
||||
return stats.isDirectory() ? root : null
|
||||
stats = await statPath(root)
|
||||
} catch (error) {
|
||||
if (isExpectedUnavailableDriveError(error)) {
|
||||
return null
|
||||
if (!isExpectedUnavailableDriveError(error)) {
|
||||
console.warn('[windows-drive-listing] Failed to stat drive', { root, error })
|
||||
}
|
||||
throw error
|
||||
return null
|
||||
}
|
||||
return stats.isDirectory() ? root : null
|
||||
})
|
||||
)
|
||||
return {
|
||||
|
||||
@@ -402,6 +402,7 @@ function registerRuntimeWindowLifecycle(
|
||||
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
|
||||
activate: opts.activate !== false,
|
||||
...(opts.presentation ? { presentation: opts.presentation } : {}),
|
||||
...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}),
|
||||
// Why: pre-minted tabId aligns the renderer tab id with the paneKey baked into the PTY env, so hook events route right.
|
||||
...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}),
|
||||
...(opts.leafId !== undefined ? { leafId: opts.leafId } : {}),
|
||||
@@ -472,6 +473,8 @@ function registerRuntimeWindowLifecycle(
|
||||
send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }),
|
||||
terminalDriverChanged: (ptyId, driver) =>
|
||||
send('runtime:terminalDriverChanged', { ptyId, driver }),
|
||||
nativeChatLaunchDraftResolved: (tabId, resolution) =>
|
||||
send('runtime:nativeChatLaunchDraftResolved', { tabId, ...resolution }),
|
||||
browserDriverChanged: (browserPageId, driver) =>
|
||||
send('runtime:browserDriverChanged', { browserPageId, driver })
|
||||
})
|
||||
|
||||
@@ -47,6 +47,8 @@ import type {
|
||||
} from '../shared/terminal-render-desync-evidence'
|
||||
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
|
||||
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
|
||||
import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure'
|
||||
import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification'
|
||||
import type {
|
||||
SshMutationExpectation,
|
||||
SshConnectionState,
|
||||
@@ -3156,6 +3158,7 @@ export type PreloadApi = {
|
||||
activate?: boolean
|
||||
focus?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
surfaceOwner?: false
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
splitFromLeafId?: string
|
||||
@@ -3304,6 +3307,9 @@ export type PreloadApi = {
|
||||
onTerminalDriverChanged: (
|
||||
callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void
|
||||
) => () => void
|
||||
onNativeChatLaunchDraftResolved?: (
|
||||
callback: (event: { tabId: string; text: string; createdAt: number }) => void
|
||||
) => () => void
|
||||
onBrowserDriverChanged: (
|
||||
callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void
|
||||
) => () => void
|
||||
@@ -3314,6 +3320,11 @@ export type PreloadApi = {
|
||||
name: string
|
||||
pairingCode: string
|
||||
}) => Promise<{ environment: PublicKnownRuntimeEnvironment }>
|
||||
verifyAndAddFromPairingCode: (args: {
|
||||
name: string
|
||||
pairingCode: string
|
||||
allowLoopback?: boolean
|
||||
}) => Promise<VerifyAndAddRuntimeEnvironmentResult>
|
||||
resolve: (args: { selector: string }) => Promise<PublicKnownRuntimeEnvironment>
|
||||
remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }>
|
||||
disconnect: (args: {
|
||||
@@ -3566,7 +3577,12 @@ export type PreloadApi = {
|
||||
connectionMode?: MobilePairingConnectionMode
|
||||
rotate?: boolean
|
||||
}) => Promise<
|
||||
| { available: false }
|
||||
| {
|
||||
available: false
|
||||
reason?: string
|
||||
guidance?: string
|
||||
relayFailure?: MobileRelayMintFailure
|
||||
}
|
||||
| {
|
||||
available: true
|
||||
qrDataUrl: string | null
|
||||
@@ -3574,7 +3590,7 @@ export type PreloadApi = {
|
||||
pairingUrl: string
|
||||
endpoint: string
|
||||
deviceId: string
|
||||
/** Mode the QR actually encodes; 'local-only' when Relay could not be attached. */
|
||||
/** Mode the QR actually encodes. */
|
||||
connectionMode: MobilePairingConnectionMode
|
||||
}
|
||||
>
|
||||
|
||||
+26
-1
@@ -22,6 +22,8 @@ import type {
|
||||
} from '../shared/agent-session-resume'
|
||||
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
|
||||
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
|
||||
import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure'
|
||||
import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification'
|
||||
import type {
|
||||
SshMutationExpectation,
|
||||
SshConnectionState,
|
||||
@@ -3721,6 +3723,7 @@ const api = {
|
||||
activate?: boolean
|
||||
focus?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
surfaceOwner?: false
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
splitFromLeafId?: string
|
||||
@@ -3746,6 +3749,7 @@ const api = {
|
||||
activate?: boolean
|
||||
focus?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
surfaceOwner?: false
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
splitFromLeafId?: string
|
||||
@@ -4250,6 +4254,16 @@ const api = {
|
||||
ipcRenderer.on('runtime:terminalDriverChanged', listener)
|
||||
return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener)
|
||||
},
|
||||
onNativeChatLaunchDraftResolved: (
|
||||
callback: (event: { tabId: string; text: string; createdAt: number }) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { tabId: string; text: string; createdAt: number }
|
||||
) => callback(data)
|
||||
ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener)
|
||||
return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener)
|
||||
},
|
||||
onBrowserDriverChanged: (
|
||||
callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void
|
||||
): (() => void) => {
|
||||
@@ -4273,6 +4287,12 @@ const api = {
|
||||
pairingCode: string
|
||||
}): Promise<{ environment: PublicKnownRuntimeEnvironment }> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args),
|
||||
verifyAndAddFromPairingCode: (args: {
|
||||
name: string
|
||||
pairingCode: string
|
||||
allowLoopback?: boolean
|
||||
}): Promise<VerifyAndAddRuntimeEnvironmentResult> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args),
|
||||
resolve: (args: { selector: string }): Promise<PublicKnownRuntimeEnvironment> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:resolve', args),
|
||||
remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> =>
|
||||
@@ -4567,7 +4587,12 @@ const api = {
|
||||
connectionMode?: MobilePairingConnectionMode
|
||||
rotate?: boolean
|
||||
}): Promise<
|
||||
| { available: false }
|
||||
| {
|
||||
available: false
|
||||
reason?: string
|
||||
guidance?: string
|
||||
relayFailure?: MobileRelayMintFailure
|
||||
}
|
||||
| {
|
||||
available: true
|
||||
qrDataUrl: string | null
|
||||
|
||||
@@ -52,10 +52,12 @@
|
||||
--m-mono: 'Menlo', 'SF Mono', monospace;
|
||||
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@@ -72,8 +74,7 @@
|
||||
gap: clamp(56px, 7vw, 112px);
|
||||
padding: 56px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
background-color: transparent;
|
||||
/* Why: the page should feel branded without putting copy over decorative
|
||||
blobs; a token-based dot texture stays quiet in both themes. */
|
||||
@@ -612,6 +613,20 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Why: empty QR frames need readable copy without looking like a stuck loader overlay. */
|
||||
.mobile-page-root .mp-qr-empty {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-qr-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -681,6 +696,14 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-pairing-layout.has-failure {
|
||||
grid-template-areas:
|
||||
'copy qr'
|
||||
'relay qr'
|
||||
'failure failure'
|
||||
'controls controls';
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-pairing-copy {
|
||||
grid-area: copy;
|
||||
min-width: 0;
|
||||
@@ -703,6 +726,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-pairing-failure {
|
||||
grid-area: failure;
|
||||
}
|
||||
|
||||
/* Why: align the QR's top with the body copy ("Scan the QR…") instead
|
||||
of the eyebrow row above it. The eyebrow row is ~22px tall + 22px
|
||||
margin-bottom, and the h2 is ~38px line-height with no top margin —
|
||||
@@ -2102,3 +2129,33 @@
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Why: the app sidebar narrows this page without changing viewport media
|
||||
queries; keep this after viewport rules so real page width wins. */
|
||||
@container (max-width: 1080px) {
|
||||
.mobile-page-root .mp-hero {
|
||||
--mp-flow-card-padding: 20px;
|
||||
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
padding: 52px 28px 28px;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-hero-copy {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-h1 {
|
||||
font-size: 44px;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-stage {
|
||||
height: min(420px, 42vh);
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-phone-frame {
|
||||
width: min(290px, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import type { DocLinkMenuState } from './rich-markdown-commands'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
|
||||
import {
|
||||
createRichMarkdownEditorConfig,
|
||||
@@ -88,6 +88,10 @@ function createConfigParams(overrides: Partial<EditorConfigParams> = {}): Editor
|
||||
}
|
||||
|
||||
describe('createRichMarkdownEditorConfig', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('disables browser spellcheck when the rich Markdown setting is off', () => {
|
||||
const config = createRichMarkdownEditorConfig(
|
||||
createConfigParams({ richMarkdownSpellcheckEnabled: false })
|
||||
@@ -101,4 +105,22 @@ describe('createRichMarkdownEditorConfig', () => {
|
||||
|
||||
expect(getSpellcheckAttribute(config)).toBe('true')
|
||||
})
|
||||
|
||||
it('flushes pending serialization when the rich editor blurs', () => {
|
||||
const setMarkdownEditorFocused = vi.fn()
|
||||
vi.stubGlobal('window', {
|
||||
api: { ui: { setMarkdownEditorFocused } }
|
||||
})
|
||||
const flushPendingSerialization = vi.fn()
|
||||
const clearAnnotationTarget = vi.fn()
|
||||
const config = createRichMarkdownEditorConfig(
|
||||
createConfigParams({ clearAnnotationTarget, flushPendingSerialization })
|
||||
)
|
||||
|
||||
config.onBlur?.({} as never)
|
||||
|
||||
expect(setMarkdownEditorFocused).toHaveBeenCalledWith(false)
|
||||
expect(clearAnnotationTarget).toHaveBeenCalledOnce()
|
||||
expect(flushPendingSerialization).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,6 +204,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
||||
onBlur: () => {
|
||||
window.api.ui.setMarkdownEditorFocused(false)
|
||||
clearAnnotationTarget()
|
||||
params.flushPendingSerialization()
|
||||
},
|
||||
onCreate: ({ editor: nextEditor }) => {
|
||||
// Why: normalizeEmptyListItems (not normalizeSoftBreaks) so hard-wrapped
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
@@ -27,6 +27,7 @@ vi.mock('./WindowsFirewallNotice', () => ({
|
||||
}))
|
||||
|
||||
import { HeroFlow, type StepIndex } from './MobileHero'
|
||||
import { MobileHeroPairingStep } from './MobileHeroPairingStep'
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
@@ -75,7 +76,10 @@ describe('HeroFlow height', () => {
|
||||
pairQrDataUrl={null}
|
||||
pairingUrl={null}
|
||||
pairingQrError={false}
|
||||
relayDegraded={false}
|
||||
relayMintFailure={null}
|
||||
onUseLan={vi.fn()}
|
||||
onRetryRelay={vi.fn()}
|
||||
onCopyRelayDiagnostics={vi.fn()}
|
||||
pairLoading={false}
|
||||
connectionMode="automatic"
|
||||
onConnectionModeChange={vi.fn()}
|
||||
@@ -115,7 +119,10 @@ describe('HeroFlow height', () => {
|
||||
pairQrDataUrl={null}
|
||||
pairingUrl={null}
|
||||
pairingQrError={false}
|
||||
relayDegraded={false}
|
||||
relayMintFailure={null}
|
||||
onUseLan={vi.fn()}
|
||||
onRetryRelay={vi.fn()}
|
||||
onCopyRelayDiagnostics={vi.fn()}
|
||||
pairLoading={false}
|
||||
connectionMode="automatic"
|
||||
onConnectionModeChange={vi.fn()}
|
||||
@@ -137,23 +144,69 @@ describe('HeroFlow height', () => {
|
||||
expect(screen.getByText('Step 1 of 2').closest('.mp-flow-screen')).toHaveAttribute('inert')
|
||||
})
|
||||
|
||||
it('flags a degraded Anywhere code and always shows the Relay beta note', () => {
|
||||
it('shows Relay mint failure with no QR and the beta note', () => {
|
||||
renderFlow(1, {
|
||||
pairQrDataUrl: 'data:image/png;base64,qr',
|
||||
relayDegraded: true
|
||||
pairQrDataUrl: null,
|
||||
relayMintFailure: {
|
||||
code: 'relay offline',
|
||||
stage: 'create_pairing_relay',
|
||||
message: 'relay offline'
|
||||
}
|
||||
})
|
||||
const notice = screen.getByTestId('relay-degraded-notice')
|
||||
expect(notice).toHaveTextContent('only works on your LAN or Tailscale')
|
||||
// Why: wrap-capable text item inside the fixed QR track (#9700); bare text
|
||||
// nodes in a flex row cannot shrink below max-content and overflow the track.
|
||||
expect(notice.querySelector('.min-w-0')).not.toBeNull()
|
||||
expect(notice.className).toMatch(/\bmin-w-0\b/)
|
||||
const notice = screen.getByTestId('relay-mint-failure-notice')
|
||||
expect(notice).toHaveTextContent('Couldn’t create a Relay pairing code')
|
||||
expect(notice).toHaveTextContent('Use LAN')
|
||||
expect(screen.getByText('No pairing code available')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Generate code' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Orca Relay is in beta.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the degradation notice when the code encodes what was selected', () => {
|
||||
it('explains an empty QR frame when no code has been generated yet', () => {
|
||||
renderFlow(1, { pairQrDataUrl: null, canGeneratePairing: true })
|
||||
expect(screen.getByText('Generate a pairing code to continue')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains an empty QR frame when Relay sign-in is required', () => {
|
||||
renderFlow(1, {
|
||||
pairQrDataUrl: null,
|
||||
canGeneratePairing: false,
|
||||
connectionMode: 'automatic'
|
||||
})
|
||||
expect(screen.getByText('Sign in to create a Relay pairing code')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables Relay recovery immediately and delays visible retry feedback', async () => {
|
||||
renderFlow(1, {
|
||||
pairLoading: true,
|
||||
relayMintFailure: {
|
||||
code: 'relay_mint_failed',
|
||||
stage: 'create_pairing_relay',
|
||||
message: 'Relay pairing invite request failed'
|
||||
}
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Retry Relay' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled()
|
||||
await waitFor(() => expect(screen.getByText(/Creating a new pairing code/)).toBeVisible())
|
||||
})
|
||||
|
||||
it('does not offer a futile retry when Relay is unavailable on the desktop', () => {
|
||||
renderFlow(1, {
|
||||
relayMintFailure: {
|
||||
code: 'relay_provider_unavailable',
|
||||
stage: 'provider_missing',
|
||||
message: 'Orca Relay is not available on this desktop'
|
||||
}
|
||||
})
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Orca Relay isn’t available on this desktop'
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('hides the mint-failure notice when a Relay QR is shown', () => {
|
||||
renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' })
|
||||
expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an encoder error while keeping the copy fallback enabled', () => {
|
||||
@@ -165,4 +218,83 @@ describe('HeroFlow height', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('couldn’t be rendered as a QR code')
|
||||
expect(screen.getByRole('button', { name: /Copy pairing code/ })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('moves focus to the pairing-code action after recovery succeeds', () => {
|
||||
const props: React.ComponentProps<typeof MobileHeroPairingStep> = {
|
||||
pairQrDataUrl: null,
|
||||
pairingUrl: null,
|
||||
pairingQrError: false,
|
||||
relayMintFailure: {
|
||||
code: 'relay_mint_failed',
|
||||
stage: 'create_pairing_relay',
|
||||
message: 'Relay pairing invite request failed'
|
||||
},
|
||||
onUseLan: vi.fn(),
|
||||
onRetryRelay: vi.fn(),
|
||||
onCopyRelayDiagnostics: vi.fn(),
|
||||
pairLoading: false,
|
||||
connectionMode: 'automatic',
|
||||
onConnectionModeChange: vi.fn(),
|
||||
onRegeneratePairing: vi.fn(),
|
||||
canGeneratePairing: true,
|
||||
onCopyPairingCode: vi.fn(),
|
||||
networkInterfaces: [],
|
||||
selectedAddress: undefined,
|
||||
onSelectedAddressChange: vi.fn(),
|
||||
beforeCustomAddressChange: vi.fn().mockResolvedValue(true),
|
||||
onRefreshNetworkInterfaces: vi.fn(),
|
||||
refreshingNetworkInterfaces: false
|
||||
}
|
||||
const { rerender } = render(<MobileHeroPairingStep {...props} />)
|
||||
screen.getByRole('button', { name: 'Retry Relay' }).focus()
|
||||
|
||||
rerender(
|
||||
<MobileHeroPairingStep
|
||||
{...props}
|
||||
pairQrDataUrl="data:image/png;base64,qr"
|
||||
pairingUrl="orca://pair#ready"
|
||||
relayMintFailure={null}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Copy pairing code' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps focus on a persistent control when an ordinary remint finishes', () => {
|
||||
const props: React.ComponentProps<typeof MobileHeroPairingStep> = {
|
||||
pairQrDataUrl: null,
|
||||
pairingUrl: null,
|
||||
pairingQrError: false,
|
||||
relayMintFailure: null,
|
||||
onUseLan: vi.fn(),
|
||||
onRetryRelay: vi.fn(),
|
||||
onCopyRelayDiagnostics: vi.fn(),
|
||||
pairLoading: true,
|
||||
connectionMode: 'local-only',
|
||||
onConnectionModeChange: vi.fn(),
|
||||
onRegeneratePairing: vi.fn(),
|
||||
canGeneratePairing: true,
|
||||
onCopyPairingCode: vi.fn(),
|
||||
networkInterfaces: [],
|
||||
selectedAddress: undefined,
|
||||
onSelectedAddressChange: vi.fn(),
|
||||
beforeCustomAddressChange: vi.fn().mockResolvedValue(true),
|
||||
onRefreshNetworkInterfaces: vi.fn(),
|
||||
refreshingNetworkInterfaces: false
|
||||
}
|
||||
const { rerender } = render(<MobileHeroPairingStep {...props} />)
|
||||
const refresh = screen.getByRole('button', { name: 'Refresh network interfaces' })
|
||||
refresh.focus()
|
||||
|
||||
rerender(
|
||||
<MobileHeroPairingStep
|
||||
{...props}
|
||||
pairQrDataUrl="data:image/png;base64,qr"
|
||||
pairingUrl="orca://pair#ready"
|
||||
pairLoading={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(refresh).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, CircleAlert, Copy, RefreshCw } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowRight, Copy } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
|
||||
import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons'
|
||||
import { NetworkInterfacePicker } from './NetworkInterfacePicker'
|
||||
import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions'
|
||||
import { MobileRelayBetaNotice } from '../settings/MobileRelayBetaNotice'
|
||||
import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy'
|
||||
import { WindowsFirewallNotice } from './WindowsFirewallNotice'
|
||||
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
|
||||
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
|
||||
import { MobileHeroPairingStep } from './MobileHeroPairingStep'
|
||||
export { HeroIntro } from './MobileHeroIntro'
|
||||
export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -16,18 +14,6 @@ import { translate } from '@/i18n/i18n'
|
||||
export type Platform = 'ios' | 'android'
|
||||
export type StepIndex = 0 | 1
|
||||
|
||||
// Why: header copy needs to refer to the *user's* device by its native name.
|
||||
function getDeviceLabel(): string {
|
||||
const ua = navigator.userAgent
|
||||
if (ua.includes('Mac')) {
|
||||
return 'Mac'
|
||||
}
|
||||
if (ua.includes('Windows')) {
|
||||
return 'PC'
|
||||
}
|
||||
return 'computer'
|
||||
}
|
||||
|
||||
type HeroFlowProps = {
|
||||
stepIdx: StepIndex
|
||||
platform: Platform
|
||||
@@ -41,8 +27,10 @@ type HeroFlowProps = {
|
||||
pairQrDataUrl: string | null
|
||||
pairingUrl: string | null
|
||||
pairingQrError: boolean
|
||||
/** True when the shown QR degraded to local-only under an Anywhere selection. */
|
||||
relayDegraded: boolean
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
onUseLan: () => void
|
||||
onRetryRelay: () => void
|
||||
onCopyRelayDiagnostics: () => void
|
||||
pairLoading: boolean
|
||||
connectionMode: MobilePairingConnectionMode
|
||||
onConnectionModeChange: (mode: MobilePairingConnectionMode) => void
|
||||
@@ -73,7 +61,10 @@ export function HeroFlow({
|
||||
pairQrDataUrl,
|
||||
pairingUrl,
|
||||
pairingQrError,
|
||||
relayDegraded,
|
||||
relayMintFailure,
|
||||
onUseLan,
|
||||
onRetryRelay,
|
||||
onCopyRelayDiagnostics,
|
||||
pairLoading,
|
||||
connectionMode,
|
||||
onConnectionModeChange,
|
||||
@@ -202,13 +193,7 @@ export function HeroFlow({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mp-qr mp-qr-large"
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.7af266b80d',
|
||||
'Install QR code'
|
||||
)}
|
||||
>
|
||||
<div className="mp-qr mp-qr-large">
|
||||
{installQrUrl ? (
|
||||
<img
|
||||
src={installQrUrl}
|
||||
@@ -227,155 +212,27 @@ export function HeroFlow({
|
||||
aria-hidden={stepIdx !== 1}
|
||||
inert={stepIdx !== 1}
|
||||
>
|
||||
<div className="mp-pairing-layout">
|
||||
<div className="mp-step2-copy mp-pairing-copy">
|
||||
<div className="mp-eyebrow-row">
|
||||
<div className="mp-step-num">2</div>
|
||||
<span className="mp-eyebrow">
|
||||
{translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mp-h2">
|
||||
{translate('auto.components.mobile.MobileHero.901c98bb93', 'Pair this')}{' '}
|
||||
{getDeviceLabel()}.
|
||||
</h2>
|
||||
<p className="mp-lead-sm">
|
||||
{translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '}
|
||||
<strong>
|
||||
{translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')}
|
||||
</strong>
|
||||
{translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mp-pairing-relay">
|
||||
<MobilePairingConnectionOptions
|
||||
value={connectionMode}
|
||||
onChange={onConnectionModeChange}
|
||||
compact
|
||||
/>
|
||||
<MobileRelayBetaNotice className="mt-1.5" />
|
||||
</div>
|
||||
<div className="mp-qr-stack mp-pairing-qr">
|
||||
<div
|
||||
className="mp-qr mp-qr-large"
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.bb0074ce11',
|
||||
'Pairing QR code'
|
||||
)}
|
||||
aria-busy={pairLoading}
|
||||
>
|
||||
{pairQrDataUrl ? (
|
||||
<img
|
||||
src={pairQrDataUrl}
|
||||
alt={translate('auto.components.mobile.MobileHero.27735e5f4e', 'Pairing QR')}
|
||||
className={cn(pairLoading && 'mp-qr-refreshing')}
|
||||
/>
|
||||
) : null}
|
||||
{pairLoading ? (
|
||||
<span className="mp-qr-loading">
|
||||
{translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mp-link-under"
|
||||
onClick={onRegeneratePairing}
|
||||
// Why: signed-out Anywhere can't serve Relay; disabling avoids
|
||||
// minting a local-only QR under the Relay label. Sign in or pick
|
||||
// LAN (shown in the path options above) to enable it.
|
||||
disabled={pairLoading || !canGeneratePairing}
|
||||
>
|
||||
{pairLoading
|
||||
? translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')
|
||||
: pairQrDataUrl
|
||||
? translate('auto.components.mobile.MobileHero.e59a252eca', 'Regenerate code')
|
||||
: translate('auto.components.mobile.MobileHero.a6cffbbb0b', 'Generate code')}
|
||||
</button>
|
||||
{relayDegraded ? (
|
||||
<p
|
||||
className="flex w-full min-w-0 items-start gap-1.5 text-xs text-muted-foreground"
|
||||
data-testid="relay-degraded-notice"
|
||||
>
|
||||
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
{/* Why: min-w-0 so the flex text item can wrap inside the fixed QR track (#9700). */}
|
||||
<span className="min-w-0">
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.relayDegradedNotice',
|
||||
'Relay couldn’t be reached — this code only works on your LAN or Tailscale.'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{pairingQrError ? (
|
||||
<p
|
||||
className="flex w-full min-w-0 items-start gap-1.5 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<span className="min-w-0">
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.pairingQrError',
|
||||
'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mp-pairing-controls">
|
||||
<div className="mp-network-row">
|
||||
<span className="mp-network-label">
|
||||
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
|
||||
</span>
|
||||
<NetworkInterfacePicker
|
||||
networkInterfaces={networkInterfaces}
|
||||
selectedAddress={selectedAddress}
|
||||
onSelectedAddressChange={onSelectedAddressChange}
|
||||
beforeCustomAddressChange={beforeCustomAddressChange}
|
||||
// Why: direct-first and local-only pairing both advertise a
|
||||
// local route; keeping it visible also prevents mode shifts.
|
||||
disabled={false}
|
||||
className="mp-network-select"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('mp-network-refresh', refreshingNetworkInterfaces && 'is-spinning')}
|
||||
onClick={onRefreshNetworkInterfaces}
|
||||
disabled={refreshingNetworkInterfaces}
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.85067b9e06',
|
||||
'Refresh network interfaces'
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.mobile.MobileHero.85067b9e06',
|
||||
'Refresh network interfaces'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mp-inline-actions">
|
||||
<span className="mp-action-divider">
|
||||
{translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mp-text-link"
|
||||
onClick={onCopyPairingCode}
|
||||
disabled={!pairingUrl || pairLoading}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
{translate('auto.components.mobile.MobileHero.010dddcf27', 'Copy pairing code')}
|
||||
</button>
|
||||
</div>
|
||||
<WindowsFirewallNotice
|
||||
pairingReady={pairQrDataUrl != null}
|
||||
address={selectedAddress}
|
||||
className="mt-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MobileHeroPairingStep
|
||||
pairQrDataUrl={pairQrDataUrl}
|
||||
pairingUrl={pairingUrl}
|
||||
pairingQrError={pairingQrError}
|
||||
relayMintFailure={relayMintFailure}
|
||||
onUseLan={onUseLan}
|
||||
onRetryRelay={onRetryRelay}
|
||||
onCopyRelayDiagnostics={onCopyRelayDiagnostics}
|
||||
pairLoading={pairLoading}
|
||||
connectionMode={connectionMode}
|
||||
onConnectionModeChange={onConnectionModeChange}
|
||||
onRegeneratePairing={onRegeneratePairing}
|
||||
canGeneratePairing={canGeneratePairing}
|
||||
onCopyPairingCode={onCopyPairingCode}
|
||||
networkInterfaces={networkInterfaces}
|
||||
selectedAddress={selectedAddress}
|
||||
onSelectedAddressChange={onSelectedAddressChange}
|
||||
beforeCustomAddressChange={beforeCustomAddressChange}
|
||||
onRefreshNetworkInterfaces={onRefreshNetworkInterfaces}
|
||||
refreshingNetworkInterfaces={refreshingNetworkInterfaces}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { CircleAlert, Copy, RefreshCw } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
|
||||
import { NetworkInterfacePicker } from './NetworkInterfacePicker'
|
||||
import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions'
|
||||
import { MobileRelayBetaNotice } from '../settings/MobileRelayBetaNotice'
|
||||
import { MobileRelayMintFailureNotice } from './mobile-relay-mint-failure-notice'
|
||||
import { WindowsFirewallNotice } from './WindowsFirewallNotice'
|
||||
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
|
||||
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/** Why: one full sentence per device kind so translators own word order and punctuation. */
|
||||
function pairDeviceHeading(): string {
|
||||
const ua = navigator.userAgent
|
||||
if (ua.includes('Mac')) {
|
||||
return translate('auto.components.mobile.MobileHero.pairThisMac', 'Pair this Mac.')
|
||||
}
|
||||
if (ua.includes('Windows')) {
|
||||
return translate('auto.components.mobile.MobileHero.pairThisPc', 'Pair this PC.')
|
||||
}
|
||||
return translate('auto.components.mobile.MobileHero.pairThisComputer', 'Pair this computer.')
|
||||
}
|
||||
|
||||
/** Short copy for the QR frame when no image can be shown. */
|
||||
function emptyPairingQrMessage(args: {
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
canGeneratePairing: boolean
|
||||
connectionMode: MobilePairingConnectionMode
|
||||
pairingQrError: boolean
|
||||
pairingUrl: string | null
|
||||
}): string {
|
||||
if (args.relayMintFailure != null) {
|
||||
return translate('auto.components.mobile.MobileHero.noRelayCode', 'No pairing code available')
|
||||
}
|
||||
if (!args.canGeneratePairing && args.connectionMode === 'automatic') {
|
||||
return translate(
|
||||
'auto.components.mobile.MobileHero.qrSignInRequired',
|
||||
'Sign in to create a Relay pairing code'
|
||||
)
|
||||
}
|
||||
if (args.pairingQrError && args.pairingUrl != null) {
|
||||
return translate(
|
||||
'auto.components.mobile.MobileHero.qrRenderFailed',
|
||||
'QR couldn’t be rendered — copy the code below'
|
||||
)
|
||||
}
|
||||
if (!args.canGeneratePairing) {
|
||||
return translate('auto.components.mobile.MobileHero.noPairingCode', 'No pairing code available')
|
||||
}
|
||||
return translate(
|
||||
'auto.components.mobile.MobileHero.qrGeneratePrompt',
|
||||
'Generate a pairing code to continue'
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileHeroPairingStep({
|
||||
pairQrDataUrl,
|
||||
pairingUrl,
|
||||
pairingQrError,
|
||||
relayMintFailure,
|
||||
onUseLan,
|
||||
onRetryRelay,
|
||||
onCopyRelayDiagnostics,
|
||||
pairLoading,
|
||||
connectionMode,
|
||||
onConnectionModeChange,
|
||||
onRegeneratePairing,
|
||||
canGeneratePairing,
|
||||
onCopyPairingCode,
|
||||
networkInterfaces,
|
||||
selectedAddress,
|
||||
onSelectedAddressChange,
|
||||
beforeCustomAddressChange,
|
||||
onRefreshNetworkInterfaces,
|
||||
refreshingNetworkInterfaces
|
||||
}: {
|
||||
pairQrDataUrl: string | null
|
||||
pairingUrl: string | null
|
||||
pairingQrError: boolean
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
onUseLan: () => void
|
||||
onRetryRelay: () => void
|
||||
onCopyRelayDiagnostics: () => void
|
||||
pairLoading: boolean
|
||||
connectionMode: MobilePairingConnectionMode
|
||||
onConnectionModeChange: (mode: MobilePairingConnectionMode) => void
|
||||
onRegeneratePairing: () => void
|
||||
canGeneratePairing: boolean
|
||||
onCopyPairingCode: () => void
|
||||
networkInterfaces: readonly MobileNetworkInterface[]
|
||||
selectedAddress: string | undefined
|
||||
onSelectedAddressChange: (address: string) => void
|
||||
beforeCustomAddressChange: (address: string) => Promise<boolean>
|
||||
onRefreshNetworkInterfaces: () => void
|
||||
refreshingNetworkInterfaces: boolean
|
||||
}): React.JSX.Element {
|
||||
const copyPairingCodeRef = useRef<HTMLButtonElement | null>(null)
|
||||
const pairingWasReadyRef = useRef(pairingUrl != null && !pairLoading)
|
||||
const emptyQrMessage =
|
||||
!pairLoading && pairQrDataUrl == null
|
||||
? emptyPairingQrMessage({
|
||||
relayMintFailure,
|
||||
canGeneratePairing,
|
||||
connectionMode,
|
||||
pairingQrError,
|
||||
pairingUrl
|
||||
})
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
const pairingReady = pairingUrl != null && !pairLoading
|
||||
const becameReady = !pairingWasReadyRef.current && pairingReady
|
||||
pairingWasReadyRef.current = pairingReady
|
||||
if (becameReady && document.activeElement === document.body) {
|
||||
copyPairingCodeRef.current?.focus()
|
||||
}
|
||||
}, [pairLoading, pairingUrl])
|
||||
|
||||
return (
|
||||
<div className={cn('mp-pairing-layout', relayMintFailure != null && 'has-failure')}>
|
||||
<div className="mp-step2-copy mp-pairing-copy">
|
||||
<div className="mp-eyebrow-row">
|
||||
<div className="mp-step-num">2</div>
|
||||
<span className="mp-eyebrow">
|
||||
{translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mp-h2">{pairDeviceHeading()}</h2>
|
||||
<p className="mp-lead-sm">
|
||||
{translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '}
|
||||
<strong>
|
||||
{translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')}
|
||||
</strong>
|
||||
{translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mp-pairing-relay">
|
||||
<MobilePairingConnectionOptions
|
||||
value={connectionMode}
|
||||
onChange={onConnectionModeChange}
|
||||
compact
|
||||
relayMintFailed={relayMintFailure != null}
|
||||
relayMintRetrying={relayMintFailure != null && pairLoading}
|
||||
/>
|
||||
<MobileRelayBetaNotice className="mt-1.5" />
|
||||
</div>
|
||||
{relayMintFailure != null ? (
|
||||
<MobileRelayMintFailureNotice
|
||||
className="mp-pairing-failure"
|
||||
failure={relayMintFailure}
|
||||
onUseLan={onUseLan}
|
||||
onRetry={onRetryRelay}
|
||||
onCopyDiagnostics={onCopyRelayDiagnostics}
|
||||
compact
|
||||
busy={pairLoading}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mp-qr-stack mp-pairing-qr">
|
||||
<div className="mp-qr mp-qr-large" aria-busy={pairLoading}>
|
||||
{pairQrDataUrl ? (
|
||||
<img
|
||||
src={pairQrDataUrl}
|
||||
alt={translate('auto.components.mobile.MobileHero.27735e5f4e', 'Pairing QR')}
|
||||
className={cn(pairLoading && 'mp-qr-refreshing')}
|
||||
/>
|
||||
) : null}
|
||||
{pairLoading ? (
|
||||
<span className="mp-qr-loading">
|
||||
{translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')}
|
||||
</span>
|
||||
) : null}
|
||||
{emptyQrMessage != null ? (
|
||||
<span className="mp-qr-empty text-center text-xs text-muted-foreground px-3">
|
||||
{emptyQrMessage}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
{pairQrDataUrl != null && !pairLoading
|
||||
? translate('auto.components.mobile.MobileHero.pairingCodeReady', 'Pairing code ready')
|
||||
: ''}
|
||||
</span>
|
||||
{relayMintFailure == null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mp-link-under"
|
||||
onClick={onRegeneratePairing}
|
||||
disabled={pairLoading || !canGeneratePairing}
|
||||
>
|
||||
{pairLoading
|
||||
? translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')
|
||||
: pairQrDataUrl
|
||||
? translate('auto.components.mobile.MobileHero.e59a252eca', 'Regenerate code')
|
||||
: translate('auto.components.mobile.MobileHero.a6cffbbb0b', 'Generate code')}
|
||||
</button>
|
||||
) : null}
|
||||
{pairingQrError ? (
|
||||
<p
|
||||
className="flex w-full min-w-0 items-start gap-1.5 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<span className="min-w-0">
|
||||
{translate(
|
||||
'auto.components.mobile.MobileHero.pairingQrError',
|
||||
'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mp-pairing-controls">
|
||||
<div className="mp-network-row">
|
||||
<span className="mp-network-label">
|
||||
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
|
||||
</span>
|
||||
<NetworkInterfacePicker
|
||||
networkInterfaces={networkInterfaces}
|
||||
selectedAddress={selectedAddress}
|
||||
onSelectedAddressChange={onSelectedAddressChange}
|
||||
beforeCustomAddressChange={beforeCustomAddressChange}
|
||||
disabled={false}
|
||||
className="mp-network-select"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('mp-network-refresh', refreshingNetworkInterfaces && 'is-spinning')}
|
||||
onClick={onRefreshNetworkInterfaces}
|
||||
disabled={refreshingNetworkInterfaces}
|
||||
aria-label={translate(
|
||||
'auto.components.mobile.MobileHero.85067b9e06',
|
||||
'Refresh network interfaces'
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.mobile.MobileHero.85067b9e06',
|
||||
'Refresh network interfaces'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mp-inline-actions">
|
||||
<span className="mp-action-divider">
|
||||
{translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")}
|
||||
</span>
|
||||
<button
|
||||
ref={copyPairingCodeRef}
|
||||
type="button"
|
||||
className="mp-text-link"
|
||||
onClick={onCopyPairingCode}
|
||||
disabled={!pairingUrl || pairLoading}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
{translate('auto.components.mobile.MobileHero.010dddcf27', 'Copy pairing code')}
|
||||
</button>
|
||||
</div>
|
||||
<WindowsFirewallNotice
|
||||
pairingReady={pairQrDataUrl != null}
|
||||
address={selectedAddress}
|
||||
className="mt-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
|
||||
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
|
||||
|
||||
type StoreState = {
|
||||
closeMobilePage: () => void
|
||||
@@ -48,6 +49,8 @@ vi.mock('./MobilePageContent', () => ({
|
||||
pairQrDataUrl: string | null
|
||||
pairingUrl: string | null
|
||||
pairingQrError: boolean
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
onRetryRelay: () => void
|
||||
stage: string | null
|
||||
stepIdx: number
|
||||
}) => (
|
||||
@@ -59,6 +62,7 @@ vi.mock('./MobilePageContent', () => ({
|
||||
<span data-testid="pairing-qr">{props.pairQrDataUrl ?? 'none'}</span>
|
||||
<span data-testid="pairing-url">{props.pairingUrl ?? 'none'}</span>
|
||||
<span data-testid="pairing-qr-error">{String(props.pairingQrError)}</span>
|
||||
<span data-testid="relay-failure">{props.relayMintFailure?.stage ?? 'none'}</span>
|
||||
<button type="button" onClick={props.enterFlow}>
|
||||
Enter flow
|
||||
</button>
|
||||
@@ -71,6 +75,9 @@ vi.mock('./MobilePageContent', () => ({
|
||||
<button type="button" onClick={() => props.handleConnectionModeChange('local-only')}>
|
||||
LAN
|
||||
</button>
|
||||
<button type="button" onClick={props.onRetryRelay}>
|
||||
Retry Relay
|
||||
</button>
|
||||
<button type="button" onClick={() => props.handleAddressChange('10.0.0.2')}>
|
||||
Change address
|
||||
</button>
|
||||
@@ -310,7 +317,90 @@ describe('MobilePage pairing connection mode', () => {
|
||||
expect(screen.getByTestId('pairing-url')).toHaveTextContent('copy-fallback')
|
||||
})
|
||||
|
||||
it('does not commit a custom address when its real QR preflight fails', async () => {
|
||||
it('surfaces Relay failure and retries with a rotated credential', async () => {
|
||||
getPairingQR.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: 'relay_mint_failed',
|
||||
relayFailure: {
|
||||
code: 'relay_control_not_active',
|
||||
stage: 'create_pairing_relay',
|
||||
message: 'Relay pairing invite request failed'
|
||||
}
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await openPairingStep()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('relay-failure')).toHaveTextContent('create_pairing_relay')
|
||||
)
|
||||
|
||||
getPairingQR.mockResolvedValueOnce({
|
||||
available: true,
|
||||
qrDataUrl: 'data:image/png;base64,retried',
|
||||
pairingUrl: 'orca://pair#retried',
|
||||
endpoint: 'ws://host',
|
||||
connectionMode: 'automatic'
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Retry Relay' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getPairingQR).toHaveBeenLastCalledWith({
|
||||
connectionMode: 'automatic',
|
||||
rotate: true
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('retried'))
|
||||
expect(screen.getByTestId('relay-failure')).toHaveTextContent('none')
|
||||
})
|
||||
|
||||
it('switches to LAN while a Relay retry is still unresolved', async () => {
|
||||
getPairingQR.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: 'relay_mint_failed',
|
||||
relayFailure: {
|
||||
code: 'relay_mint_failed',
|
||||
stage: 'create_pairing_relay',
|
||||
message: 'Relay pairing invite request failed'
|
||||
}
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await openPairingStep()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('relay-failure')).toHaveTextContent('create_pairing_relay')
|
||||
)
|
||||
|
||||
let resolveRetry: ((value: Record<string, unknown>) => void) | undefined
|
||||
getPairingQR.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRetry = resolve
|
||||
})
|
||||
)
|
||||
getPairingQR.mockResolvedValueOnce({
|
||||
available: true,
|
||||
qrDataUrl: 'data:image/png;base64,local',
|
||||
pairingUrl: 'orca://pair#local',
|
||||
endpoint: 'ws://host',
|
||||
connectionMode: 'local-only'
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Retry Relay' }))
|
||||
await user.click(screen.getByRole('button', { name: 'LAN' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getPairingQR).toHaveBeenLastCalledWith({ connectionMode: 'local-only' })
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,local'))
|
||||
resolveRetry?.({
|
||||
available: true,
|
||||
qrDataUrl: 'data:image/png;base64,stale-relay',
|
||||
pairingUrl: 'orca://pair#stale-relay',
|
||||
endpoint: 'ws://relay',
|
||||
connectionMode: 'automatic'
|
||||
})
|
||||
await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,local'))
|
||||
expect(screen.getByTestId('mode')).toHaveTextContent('local-only')
|
||||
})
|
||||
|
||||
it('does not commit a custom direct address when its QR preflight fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
await openPairingStep()
|
||||
await waitFor(() => expect(getPairingQR).toHaveBeenCalledTimes(1))
|
||||
@@ -331,7 +421,6 @@ describe('MobilePage pairing connection mode', () => {
|
||||
connectionMode: 'automatic'
|
||||
})
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(getPairingQR).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useMobilePairingGeneration } from './use-mobile-pairing-generation'
|
||||
import { useMobilePairingQrInvalidation } from './use-mobile-pairing-qr-invalidation'
|
||||
import { useMobileInstallActions } from './use-mobile-install-actions'
|
||||
import { useMobilePagePairedDevices } from './use-mobile-page-paired-devices'
|
||||
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
|
||||
|
||||
export default function MobilePage(): React.JSX.Element {
|
||||
const [stepIdx, setStepIdx] = useState<StepIndex>(0)
|
||||
@@ -33,10 +34,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
const [pairQrDataUrl, setPairQrDataUrl] = useState<string | null>(null)
|
||||
const [pairingUrl, setPairingUrl] = useState<string | null>(null)
|
||||
const [pairingQrError, setPairingQrError] = useState(false)
|
||||
// Mode the displayed QR actually encodes; can be 'local-only' under an
|
||||
// Anywhere selection when Relay provisioning degraded server-side.
|
||||
const [encodedConnectionMode, setEncodedConnectionMode] =
|
||||
useState<MobilePairingConnectionMode | null>(null)
|
||||
const [relayMintFailure, setRelayMintFailure] = useState<MobileRelayMintFailure | null>(null)
|
||||
const [pairLoading, setPairLoading] = useState(false)
|
||||
const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected')
|
||||
const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode()
|
||||
@@ -77,7 +75,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
setEncodedConnectionMode
|
||||
setRelayMintFailure
|
||||
})
|
||||
|
||||
const handleConnectionModeChange = useCallback(
|
||||
@@ -88,12 +86,43 @@ export default function MobilePage(): React.JSX.Element {
|
||||
// Why: persist the pick and update local state. The QR invalidation +
|
||||
// rotate-regenerate is handled centrally by useMobilePairingQrInvalidation
|
||||
// (below), which also covers cross-window preference syncs.
|
||||
setRelayMintFailure(null)
|
||||
setConnectionMode(nextMode)
|
||||
void updateSettings({ mobilePairingConnectionMode: nextMode })
|
||||
},
|
||||
[connectionMode, updateSettings, setConnectionMode]
|
||||
)
|
||||
|
||||
const copyRelayDiagnostics = useCallback(async (): Promise<void> => {
|
||||
if (relayMintFailure == null) {
|
||||
return
|
||||
}
|
||||
// Why: users share this payload — the selected address would leak a LAN/Tailscale IP or hostname.
|
||||
const payload = {
|
||||
kind: 'mobile_pairing_relay_failure',
|
||||
preferredConnectionMode: connectionMode,
|
||||
failure: relayMintFailure,
|
||||
at: new Date().toISOString()
|
||||
}
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(JSON.stringify(payload, null, 2))
|
||||
if (mountedRef.current) {
|
||||
toast.success(
|
||||
translate('auto.components.mobile.MobilePage.diagnosticsCopied', 'Diagnostics copied')
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.mobile.MobilePage.diagnosticsCopyFailed',
|
||||
'Failed to copy diagnostics'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [connectionMode, mountedRef, relayMintFailure])
|
||||
|
||||
useMobilePairingQrInvalidation({
|
||||
connectionMode,
|
||||
signedIn,
|
||||
@@ -104,6 +133,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
setRelayMintFailure,
|
||||
regenerate: (mode, opts) => void generatePairing(opts.rotate, undefined, mode)
|
||||
})
|
||||
|
||||
@@ -168,8 +198,6 @@ export default function MobilePage(): React.JSX.Element {
|
||||
// not snap it back to a tailnet/LAN fallback.
|
||||
const isManual = !networkInterfaces.some((iface) => iface.address === address)
|
||||
setAddressIsManual(isManual)
|
||||
// Switching network must remint so the QR encodes the new endpoint —
|
||||
// but only when the selected path may honestly mint (not signed-out Anywhere).
|
||||
if (canMintMobilePairingOffer({ connectionMode, signedIn })) {
|
||||
void generatePairing(true, address)
|
||||
}
|
||||
@@ -237,7 +265,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
setPairQrDataUrl(null)
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setEncodedConnectionMode(null)
|
||||
setRelayMintFailure(null)
|
||||
showFirstPairingFlow()
|
||||
}
|
||||
|
||||
@@ -248,7 +276,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
setPairQrDataUrl(null)
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setEncodedConnectionMode(null)
|
||||
setRelayMintFailure(null)
|
||||
showPairAnotherDeviceFlow()
|
||||
}
|
||||
|
||||
@@ -299,11 +327,12 @@ export default function MobilePage(): React.JSX.Element {
|
||||
pairQrDataUrl={pairQrDataUrl}
|
||||
pairingUrl={pairingUrl}
|
||||
pairingQrError={pairingQrError}
|
||||
relayDegraded={
|
||||
pairQrDataUrl != null &&
|
||||
connectionMode === 'automatic' &&
|
||||
encodedConnectionMode === 'local-only'
|
||||
relayMintFailure={
|
||||
connectionMode === 'automatic' && pairQrDataUrl == null ? relayMintFailure : null
|
||||
}
|
||||
onUseLan={() => handleConnectionModeChange('local-only')}
|
||||
onRetryRelay={() => void generatePairing(true)}
|
||||
onCopyRelayDiagnostics={() => void copyRelayDiagnostics()}
|
||||
platform={platform}
|
||||
refreshingNetworkInterfaces={refreshingNetworkInterfaces}
|
||||
revokeDevice={(id) => void revokeDevice(id)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { MobilePageStage } from './mobile-page-stage'
|
||||
import { MobilePageToolbar } from './MobilePageToolbar'
|
||||
import { PhoneCarousel } from './PhoneCarousel'
|
||||
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
|
||||
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
|
||||
|
||||
type MobilePageContentProps = {
|
||||
closeMobilePage: () => void
|
||||
@@ -39,7 +40,10 @@ type MobilePageContentProps = {
|
||||
pairQrDataUrl: string | null
|
||||
pairingUrl: string | null
|
||||
pairingQrError: boolean
|
||||
relayDegraded: boolean
|
||||
relayMintFailure: MobileRelayMintFailure | null
|
||||
onUseLan: () => void
|
||||
onRetryRelay: () => void
|
||||
onCopyRelayDiagnostics: () => void
|
||||
platform: Platform
|
||||
refreshingNetworkInterfaces: boolean
|
||||
revokeDevice: (id: string) => void
|
||||
@@ -78,7 +82,10 @@ export function MobilePageContent({
|
||||
pairQrDataUrl,
|
||||
pairingUrl,
|
||||
pairingQrError,
|
||||
relayDegraded,
|
||||
relayMintFailure,
|
||||
onUseLan,
|
||||
onRetryRelay,
|
||||
onCopyRelayDiagnostics,
|
||||
platform,
|
||||
refreshingNetworkInterfaces,
|
||||
revokeDevice,
|
||||
@@ -92,7 +99,7 @@ export function MobilePageContent({
|
||||
toggleMobileSidebarButton
|
||||
}: MobilePageContentProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="mobile-page-root">
|
||||
<div className="mobile-page-root scrollbar-sleek">
|
||||
<MobilePageToolbar
|
||||
showMobileButton={showMobileButton}
|
||||
onClose={closeMobilePage}
|
||||
@@ -123,7 +130,10 @@ export function MobilePageContent({
|
||||
pairQrDataUrl={pairQrDataUrl}
|
||||
pairingUrl={pairingUrl}
|
||||
pairingQrError={pairingQrError}
|
||||
relayDegraded={relayDegraded}
|
||||
relayMintFailure={relayMintFailure}
|
||||
onUseLan={onUseLan}
|
||||
onRetryRelay={onRetryRelay}
|
||||
onCopyRelayDiagnostics={onCopyRelayDiagnostics}
|
||||
pairLoading={pairLoading}
|
||||
connectionMode={connectionMode}
|
||||
onConnectionModeChange={handleConnectionModeChange}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CircleAlert, Loader2 } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function MobileRelayMintFailureNotice({
|
||||
failure,
|
||||
onUseLan,
|
||||
onRetry,
|
||||
onCopyDiagnostics,
|
||||
className,
|
||||
compact = false,
|
||||
busy = false
|
||||
}: {
|
||||
failure: MobileRelayMintFailure
|
||||
onUseLan: () => void
|
||||
onRetry: () => void
|
||||
onCopyDiagnostics: () => void
|
||||
className?: string
|
||||
compact?: boolean
|
||||
busy?: boolean
|
||||
}): React.JSX.Element {
|
||||
const providerMissing = failure.stage === 'provider_missing'
|
||||
const [showBusyFeedback, setShowBusyFeedback] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!busy) {
|
||||
setShowBusyFeedback(false)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => setShowBusyFeedback(true), 200)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [busy])
|
||||
const visibleBusy = busy && showBusyFeedback
|
||||
const title = visibleBusy
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.retryingTitle',
|
||||
'Retrying Orca Relay…'
|
||||
)
|
||||
: providerMissing
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableTitle',
|
||||
'Orca Relay isn’t available on this desktop.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.title',
|
||||
'Couldn’t create a Relay pairing code.'
|
||||
)
|
||||
const body = visibleBusy
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.retryingBody',
|
||||
'Creating a new pairing code. This can take a moment over a remote connection.'
|
||||
)
|
||||
: providerMissing
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody',
|
||||
'Use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.body',
|
||||
'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 items-start gap-2 rounded-lg border p-3 text-xs',
|
||||
visibleBusy
|
||||
? 'border-border bg-muted/40 text-foreground'
|
||||
: 'border-destructive/30 bg-destructive/10 text-destructive',
|
||||
className
|
||||
)}
|
||||
data-testid="relay-mint-failure-notice"
|
||||
>
|
||||
{visibleBusy ? (
|
||||
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p
|
||||
className="min-w-0"
|
||||
role={visibleBusy ? 'status' : 'alert'}
|
||||
aria-live={visibleBusy ? 'polite' : 'assertive'}
|
||||
>
|
||||
<span className="font-medium">{title}</span> {body}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size={compact ? 'xs' : 'sm'} onClick={onUseLan}>
|
||||
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')}
|
||||
</Button>
|
||||
{!providerMissing ? (
|
||||
<Button
|
||||
type="button"
|
||||
size={compact ? 'xs' : 'sm'}
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
disabled={busy}
|
||||
className="w-28"
|
||||
>
|
||||
{visibleBusy ? <Loader2 className="animate-spin" /> : null}
|
||||
{visibleBusy
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.retrying',
|
||||
'Retrying…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.retry',
|
||||
'Retry Relay'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size={compact ? 'xs' : 'sm'}
|
||||
variant="ghost"
|
||||
onClick={onCopyDiagnostics}
|
||||
>
|
||||
{translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.copyDiagnostics',
|
||||
'Copy diagnostics'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,13 +5,15 @@ import {
|
||||
canMintMobilePairingOffer,
|
||||
type MobilePairingConnectionMode
|
||||
} from '../../../../shared/mobile-pairing-connection-mode'
|
||||
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
|
||||
|
||||
type MutableRef<T> = { current: T }
|
||||
|
||||
/**
|
||||
* Mints (or rotates) a pairing QR. Every caller must go through this path so
|
||||
* signed-out Anywhere is refused rather than silently degraded to a local-only
|
||||
* code under the Relay label.
|
||||
* code under the Relay label. Anywhere mint failures surface relayFailure and
|
||||
* clear any QR.
|
||||
*/
|
||||
export function useMobilePairingGeneration(params: {
|
||||
connectionMode: MobilePairingConnectionMode
|
||||
@@ -24,9 +26,7 @@ export function useMobilePairingGeneration(params: {
|
||||
setPairingUrl: (value: string | null) => void
|
||||
setPairingQrError: (value: boolean) => void
|
||||
setPairLoading: (value: boolean) => void
|
||||
/** Mode the minted QR actually encodes (degraded Relay mints report
|
||||
* 'local-only'); null while no QR is shown. */
|
||||
setEncodedConnectionMode: (value: MobilePairingConnectionMode | null) => void
|
||||
setRelayMintFailure: (value: MobileRelayMintFailure | null) => void
|
||||
}): {
|
||||
generatePairing: (
|
||||
rotate: boolean,
|
||||
@@ -45,7 +45,7 @@ export function useMobilePairingGeneration(params: {
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
setEncodedConnectionMode
|
||||
setRelayMintFailure
|
||||
} = params
|
||||
|
||||
const generatePairing = useCallback(
|
||||
@@ -55,22 +55,16 @@ export function useMobilePairingGeneration(params: {
|
||||
connectionModeOverride?: MobilePairingConnectionMode
|
||||
) => {
|
||||
const preferredMode = connectionModeOverride ?? connectionMode
|
||||
// Why: every mint path (auto-generate, regenerate, address change, path
|
||||
// invalidation) must refuse signed-out Anywhere rather than degrade to a
|
||||
// local-only QR under the Relay label.
|
||||
if (!canMintMobilePairingOffer({ connectionMode: preferredMode, signedIn })) {
|
||||
return
|
||||
}
|
||||
const requestId = ++pairingRequestIdRef.current
|
||||
// Mark the request synchronously so state changes cannot make the
|
||||
// Step 2 auto-generate effect start a second offer in parallel.
|
||||
hasGeneratedRef.current = true
|
||||
if (mountedRef.current) {
|
||||
setPairLoading(true)
|
||||
}
|
||||
try {
|
||||
const address = addressOverride ?? selectedAddress
|
||||
// canMint already requires sign-in for Anywhere, so preferred is honest.
|
||||
const result = await window.api.mobile.getPairingQR({
|
||||
...(address ? { address } : {}),
|
||||
connectionMode: preferredMode,
|
||||
@@ -84,21 +78,28 @@ export function useMobilePairingGeneration(params: {
|
||||
setPairQrDataUrl(result.qrDataUrl)
|
||||
setPairingUrl(result.pairingUrl)
|
||||
setPairingQrError(result.qrDataUrl === null)
|
||||
setEncodedConnectionMode(result.connectionMode)
|
||||
setRelayMintFailure(null)
|
||||
}
|
||||
} else {
|
||||
hasGeneratedRef.current = false
|
||||
// Why: keep hasGenerated so step-2 auto-mint does not loop on failure.
|
||||
if (mountedRef.current) {
|
||||
setPairQrDataUrl(null)
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setEncodedConnectionMode(null)
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.mobile.MobilePage.b353e18de1',
|
||||
'WebSocket transport is not running'
|
||||
if (result.reason === 'relay_mint_failed' && result.relayFailure) {
|
||||
setRelayMintFailure(result.relayFailure)
|
||||
} else {
|
||||
setRelayMintFailure(null)
|
||||
// Why: IPC now forwards reason/guidance for all unavailability paths;
|
||||
// prefer that copy over a hard-coded WebSocket-only message.
|
||||
toast.error(
|
||||
result.guidance ??
|
||||
translate(
|
||||
'auto.components.mobile.MobilePage.b353e18de1',
|
||||
'WebSocket transport is not running'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -107,7 +108,7 @@ export function useMobilePairingGeneration(params: {
|
||||
setPairQrDataUrl(null)
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setEncodedConnectionMode(null)
|
||||
setRelayMintFailure(null)
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.mobile.MobilePage.4c8bd11c1a',
|
||||
@@ -127,11 +128,11 @@ export function useMobilePairingGeneration(params: {
|
||||
mountedRef,
|
||||
pairingRequestIdRef,
|
||||
selectedAddress,
|
||||
setEncodedConnectionMode,
|
||||
setPairLoading,
|
||||
setPairQrDataUrl,
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setRelayMintFailure,
|
||||
signedIn
|
||||
]
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
setPairingUrl: (value: string | null) => void
|
||||
setPairingQrError: (value: boolean) => void
|
||||
setPairLoading: (value: boolean) => void
|
||||
setRelayMintFailure?: (value: null) => void
|
||||
regenerate: (mode: MobilePairingConnectionMode, opts: { rotate: boolean }) => void
|
||||
}): void {
|
||||
const {
|
||||
@@ -35,6 +36,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
setRelayMintFailure,
|
||||
regenerate
|
||||
} = params
|
||||
const wasSignedInRef = useRef(signedIn)
|
||||
@@ -57,6 +59,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setPairQrDataUrl(null)
|
||||
setRelayMintFailure?.(null)
|
||||
if (signedIn && canMintMobilePairingOffer({ connectionMode, signedIn })) {
|
||||
// Why: rotate on the sign-in edge — the token behind the QR cleared at
|
||||
// sign-out may have been exposed, so the fresh session mints fresh.
|
||||
@@ -73,6 +76,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
setRelayMintFailure,
|
||||
regenerate
|
||||
])
|
||||
|
||||
@@ -93,6 +97,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
setPairingUrl(null)
|
||||
setPairingQrError(false)
|
||||
setPairQrDataUrl(null)
|
||||
setRelayMintFailure?.(null)
|
||||
if (shouldRegenerate && canMintMobilePairingOffer({ connectionMode, signedIn })) {
|
||||
// Why: no rotate here — the main process rotates exactly once when the
|
||||
// requested mode differs from the pending token's minted mode, so the
|
||||
@@ -113,6 +118,7 @@ export function useMobilePairingQrInvalidation(params: {
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
setRelayMintFailure,
|
||||
regenerate
|
||||
])
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
submitNativeChatPrompt
|
||||
} from './native-chat-runtime-send'
|
||||
import type { NativeChatSendHandle } from './native-chat-runtime-send'
|
||||
import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send'
|
||||
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
|
||||
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
|
||||
import {
|
||||
@@ -250,21 +251,29 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
return
|
||||
}
|
||||
const classification = classifySend(text)
|
||||
// A parked launch draft must be cleared line-by-line before the body.
|
||||
const { sendOptions } = resolveNativeChatLaunchDraftSend({
|
||||
launchDraft,
|
||||
launchDraftResolved,
|
||||
agent,
|
||||
readScreen: () => readTerminalScreen?.()
|
||||
})
|
||||
let pendingHandle: NativeChatSendHandle | null = null
|
||||
// Why: image attachments take the attachment send path even for a
|
||||
// command/unknown send, otherwise `clearImageAttachments()` below drops
|
||||
// them silently when the text starts with the agent's slash/skill prefix.
|
||||
if (classification !== 'chat' && imagePaths.length === 0) {
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text)
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions)
|
||||
} else if (imagePaths.length > 0) {
|
||||
pendingHandle = sendNativeChatMessageWithImageAttachments(
|
||||
target.settings,
|
||||
target.ptyId,
|
||||
text,
|
||||
imagePaths
|
||||
imagePaths,
|
||||
sendOptions
|
||||
)
|
||||
} else if (text.trim().length > 0) {
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text)
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions)
|
||||
} else {
|
||||
submitNativeChatPrompt(target.settings, target.ptyId)
|
||||
}
|
||||
@@ -296,8 +305,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
clearSkillOrigin()
|
||||
clearImageAttachments()
|
||||
setNotice(null)
|
||||
// Why: the send path pre-clears the TUI input line, so any launch-draft
|
||||
// prefill still parked there is gone — retire the composer seed with it.
|
||||
// The send cleared the TUI input line before its body, so retire the seed.
|
||||
useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId)
|
||||
}, [
|
||||
agent,
|
||||
@@ -308,6 +316,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
imageAttachments,
|
||||
disabled,
|
||||
isDispatchingSessionOption,
|
||||
launchDraft,
|
||||
launchDraftResolved,
|
||||
readTerminalScreen,
|
||||
resolveTarget,
|
||||
onOptimisticSend,
|
||||
onSlashCommand,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentInputLineCleared,
|
||||
planNativeChatLaunchDraftSend,
|
||||
resolveNativeChatLaunchDraftSend
|
||||
} from './native-chat-launch-draft-send'
|
||||
import {
|
||||
AGENT_TUI_CLEAR_INPUT_LINE,
|
||||
buildAgentTuiClearInputForText
|
||||
} from '../../../../shared/agent-tui-input-clear'
|
||||
|
||||
const SEEDED = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
|
||||
/** Claude's frame: the input line and its continuation rows sit last. */
|
||||
const screenHoldingDraft = [
|
||||
' ▘▘ ▝▝ ~/repo',
|
||||
'────────────────────────────────────────',
|
||||
'❯ Linked Linear issue: ABC-123',
|
||||
' https://linear.app/x/issue/ABC-123',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
|
||||
const screenPlaceholder = [
|
||||
' ▘▘ ▝▝ ~/repo',
|
||||
'────────────────────────────────────────',
|
||||
'❯ Try "create a util logging.py that..."',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
|
||||
const plan = (over: Partial<Parameters<typeof planNativeChatLaunchDraftSend>[0]> = {}) =>
|
||||
planNativeChatLaunchDraftSend({
|
||||
seededText: SEEDED,
|
||||
...over
|
||||
})
|
||||
|
||||
describe('planNativeChatLaunchDraftSend', () => {
|
||||
it('replaces the parked draft even when the composer copy is unchanged', () => {
|
||||
expect(plan()).toEqual({
|
||||
kind: 'replace-draft',
|
||||
clearInput: buildAgentTuiClearInputForText(SEEDED),
|
||||
seededText: SEEDED
|
||||
})
|
||||
})
|
||||
|
||||
it('does not submit a terminal-side edit that preserves the old short prefix', () => {
|
||||
const samePrefixEdit = [
|
||||
'────────────────────────────────────────',
|
||||
'❯ Linked Linear but terminal-side text changed',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
|
||||
const result = resolveNativeChatLaunchDraftSend({
|
||||
launchDraft: { agent: 'codex', text: SEEDED },
|
||||
launchDraftResolved: false,
|
||||
agent: 'codex',
|
||||
readScreen: () => samePrefixEdit
|
||||
})
|
||||
expect(result.plan.kind).toBe('replace-draft')
|
||||
})
|
||||
|
||||
it('keeps the ordinary send path when nothing is parked on the line', () => {
|
||||
expect(plan({ seededText: null })).toEqual({ kind: 'default' })
|
||||
expect(plan({ seededText: ' ' })).toEqual({ kind: 'default' })
|
||||
})
|
||||
|
||||
it('sizes a multi-line clear well past a single Ctrl+U', () => {
|
||||
const result = plan()
|
||||
expect(result.kind === 'replace-draft' && result.clearInput.length).toBeGreaterThan(
|
||||
AGENT_TUI_CLEAR_INPUT_LINE.length
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentInputLineCleared', () => {
|
||||
it('confirms only an observably empty prompt', () => {
|
||||
expect(agentInputLineCleared('› \n gpt-5.6 · ~/repo')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not call a different nonempty prompt cleared', () => {
|
||||
const edited = [
|
||||
'────────────────────────────────────────',
|
||||
'❯ issue: ABC-123 residue after a cursor-middle clear',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
expect(agentInputLineCleared(edited)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not ignore nonempty continuation rows after an empty prompt row', () => {
|
||||
const residue = [
|
||||
'────────────────────────────────────────',
|
||||
'❯ ',
|
||||
' suffix after a cursor-middle clear',
|
||||
'────────────────────────────────────────'
|
||||
].join('\n')
|
||||
expect(agentInputLineCleared(residue)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats placeholders and parked drafts as unconfirmed', () => {
|
||||
expect(agentInputLineCleared(screenPlaceholder)).toBe(false)
|
||||
expect(agentInputLineCleared(screenHoldingDraft)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an unreadable screen as unconfirmed', () => {
|
||||
expect(agentInputLineCleared(null)).toBe(false)
|
||||
expect(agentInputLineCleared('unparseable')).toBe(false)
|
||||
})
|
||||
|
||||
it('reads an empty prompt through serializer ANSI', () => {
|
||||
expect(agentInputLineCleared(`[2m❯[0m `)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
// Choosing how a chat send lands when the agent's TUI input line still holds a
|
||||
// launch-context draft that Orca itself injected.
|
||||
|
||||
import { buildAgentTuiClearInputForText } from '../../../../shared/agent-tui-input-clear'
|
||||
import { stripScrollbackAnsi } from './native-chat-scrape-fallback'
|
||||
|
||||
export type NativeChatLaunchDraftSendPlan =
|
||||
/** Input line holds a stale injected draft — replace it, clearing every line. */
|
||||
| { kind: 'replace-draft'; clearInput: string; seededText: string }
|
||||
/** No injected draft is parked on the line; keep the ordinary send path. */
|
||||
| { kind: 'default' }
|
||||
|
||||
/** Prompt glyphs both supported agent TUIs draw at the start of the input line. */
|
||||
const COMPOSER_PROMPT_LINE = /^\s*([❯›])\s?(.*)$/
|
||||
const CLAUDE_FRAME_LINE = /^\s*─{3,}\s*$/
|
||||
const CODEX_FOOTER_LINE = /^\s*\S.*\s[·•]\s.*$/
|
||||
|
||||
function composerContinuationIsEmpty(lines: string[], promptIndex: number, glyph: string): boolean {
|
||||
for (let index = promptIndex + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index]!
|
||||
if (
|
||||
(glyph === '❯' && CLAUDE_FRAME_LINE.test(line)) ||
|
||||
(glyph === '›' && CODEX_FOOTER_LINE.test(line))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (line.trim() !== '') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the rendered composer prompt is observably empty. Placeholder text,
|
||||
* unrelated edits, and unreadable screens are all unconfirmed.
|
||||
*/
|
||||
export function agentInputLineCleared(screen: string | null | undefined): boolean {
|
||||
if (!screen) {
|
||||
return false
|
||||
}
|
||||
const lines = stripScrollbackAnsi(screen).split('\n')
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const match = COMPOSER_PROMPT_LINE.exec(lines[index]!)
|
||||
if (match) {
|
||||
return match[2]!.trim() === '' && composerContinuationIsEmpty(lines, index, match[1]!)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* A serialized screen cannot prove the whole parked draft still matches: visual
|
||||
* wrapping loses logical-line boundaries. Always replace from the composer copy.
|
||||
*/
|
||||
export function planNativeChatLaunchDraftSend(args: {
|
||||
/** Text Orca injected into the TUI line, or null when nothing is parked there. */
|
||||
seededText: string | null | undefined
|
||||
}): NativeChatLaunchDraftSendPlan {
|
||||
const seededText = args.seededText
|
||||
if (!seededText || seededText.trim() === '') {
|
||||
return { kind: 'default' }
|
||||
}
|
||||
return {
|
||||
kind: 'replace-draft',
|
||||
clearInput: buildAgentTuiClearInputForText(seededText),
|
||||
seededText
|
||||
}
|
||||
}
|
||||
|
||||
/** What a composer send needs: which path to take, and the clear/confirm bytes
|
||||
* the ordinary send paths should use when it is a draft replacement. */
|
||||
export function resolveNativeChatLaunchDraftSend(args: {
|
||||
launchDraft: { agent: string; text: string } | null | undefined
|
||||
launchDraftResolved: boolean
|
||||
agent: string
|
||||
readScreen: () => string | null | undefined
|
||||
}): {
|
||||
plan: NativeChatLaunchDraftSendPlan
|
||||
sendOptions: { clearInput: string; confirmCleared: () => boolean } | undefined
|
||||
} {
|
||||
const { launchDraft, launchDraftResolved, agent, readScreen } = args
|
||||
// A resolved draft was already submitted or cleared TUI-side, so nothing of
|
||||
// ours is on the line any more — treating it as parked would clear or submit
|
||||
// a buffer that no longer holds it.
|
||||
const seededText =
|
||||
launchDraft && launchDraft.agent === agent && !launchDraftResolved ? launchDraft.text : null
|
||||
const plan = planNativeChatLaunchDraftSend({ seededText })
|
||||
if (plan.kind !== 'replace-draft') {
|
||||
return { plan, sendOptions: undefined }
|
||||
}
|
||||
return {
|
||||
plan,
|
||||
sendOptions: {
|
||||
clearInput: plan.clearInput,
|
||||
confirmCleared: () => agentInputLineCleared(readScreen())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
export type NativeChatPtySendQueueHandle = {
|
||||
cancel: () => void
|
||||
settleAfterMs: number
|
||||
settled: Promise<void>
|
||||
bodyStarted: () => boolean
|
||||
finished: () => boolean
|
||||
}
|
||||
@@ -97,6 +98,16 @@ export function enqueueNativeChatPtySend(
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
let release: (() => void) | null = null
|
||||
|
||||
const finishEntry = (): void => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
const resolve = release
|
||||
release = null
|
||||
resolve?.()
|
||||
}
|
||||
|
||||
const delay = (ms: number, fn: () => void): void => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
@@ -106,19 +117,17 @@ export function enqueueNativeChatPtySend(
|
||||
timers.push(timer)
|
||||
}
|
||||
|
||||
const markFinished = (): void => {
|
||||
finished = true
|
||||
}
|
||||
|
||||
const markSubmitted = (): void => {
|
||||
submitted = true
|
||||
finishEntry()
|
||||
}
|
||||
|
||||
const execute = (): Promise<void> =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
if (cancelled) {
|
||||
markFinished()
|
||||
release = null
|
||||
finished = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
@@ -126,17 +135,7 @@ export function enqueueNativeChatPtySend(
|
||||
start({ isCancelled: () => cancelled, delay, markSubmitted })
|
||||
if (durationMs <= 0) {
|
||||
markSubmitted()
|
||||
markFinished()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
// Why: always release after the declared duration so a cancel mid-flight
|
||||
// cannot stall the per-pty queue forever.
|
||||
const done = setTimeout(() => {
|
||||
markFinished()
|
||||
resolve()
|
||||
}, durationMs)
|
||||
timers.push(done)
|
||||
})
|
||||
|
||||
const runPromise =
|
||||
@@ -148,7 +147,7 @@ export function enqueueNativeChatPtySend(
|
||||
|
||||
const settleQueueEntry = (): void => {
|
||||
state.depth = Math.max(0, state.depth - 1)
|
||||
markFinished()
|
||||
finished = true
|
||||
dropHandle()
|
||||
// Why: drop the per-pty record once nothing is in flight so the map does not
|
||||
// accumulate one permanent entry per pty over a long, multi-pane session.
|
||||
@@ -157,7 +156,8 @@ export function enqueueNativeChatPtySend(
|
||||
}
|
||||
}
|
||||
|
||||
state.tail = runPromise.then(settleQueueEntry, settleQueueEntry)
|
||||
const settled = runPromise.then(settleQueueEntry, settleQueueEntry)
|
||||
state.tail = settled
|
||||
|
||||
const handle: NativeChatPtySendQueueHandle = {
|
||||
cancel: () => {
|
||||
@@ -169,20 +169,19 @@ export function enqueueNativeChatPtySend(
|
||||
clearTimeout(timer)
|
||||
}
|
||||
const shouldClear = bodyStarted && !submitted
|
||||
markFinished()
|
||||
// Why: refund only THIS sequence's charged window rather than collapsing
|
||||
// freeAt to now — later queued sends still hold the line, so a blanket
|
||||
// reset would understate the next enqueue's settle time and let a send
|
||||
// card drop while a queued Enter is still pending.
|
||||
state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs))
|
||||
release?.()
|
||||
release = null
|
||||
finishEntry()
|
||||
dropHandle()
|
||||
if (shouldClear) {
|
||||
options?.onCancelUnsubmitted?.()
|
||||
}
|
||||
},
|
||||
settleAfterMs,
|
||||
settled,
|
||||
bodyStarted: () => bodyStarted,
|
||||
finished: () => finished
|
||||
}
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
// Send-path behaviour when a launch-context draft is still parked on the agent's
|
||||
// TUI input line: the multi-line clear and its confirmation step.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const sendRuntimePtyInput = vi.fn()
|
||||
const sendRuntimePtyInputVerified = vi.fn()
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args),
|
||||
sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args)
|
||||
}))
|
||||
|
||||
import {
|
||||
NATIVE_CHAT_CLEAR_CONFIRM_MS,
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS,
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS,
|
||||
resetNativeChatPtySendQueuesForTests,
|
||||
sendNativeChatMessage,
|
||||
sendNativeChatMessageWithImageAttachments
|
||||
} from './native-chat-runtime-send'
|
||||
import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send'
|
||||
import {
|
||||
AGENT_TUI_CLEAR_INPUT_MAX,
|
||||
buildAgentTuiClearInputForText
|
||||
} from '../../../../shared/agent-tui-input-clear'
|
||||
|
||||
const SETTINGS = {} as Parameters<typeof sendNativeChatMessage>[0]
|
||||
const PTY = 'pty-launch-draft'
|
||||
const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
|
||||
const writes = (): string[] => sendRuntimePtyInput.mock.calls.map((call) => call[2] as string)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
sendRuntimePtyInput.mockClear()
|
||||
sendRuntimePtyInput.mockReturnValue(true)
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetNativeChatPtySendQueuesForTests()
|
||||
})
|
||||
|
||||
describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
it('leads with a clear sized to every line of the draft, not one Ctrl+U', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited text', { clearInput })
|
||||
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited text')])
|
||||
expect(clearInput).not.toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
})
|
||||
|
||||
it('still defaults to a single Ctrl+U when no draft is parked', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'plain')
|
||||
expect(writes()[0]).toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
})
|
||||
|
||||
it('holds the body until the clear is confirmed, then submits after the gap', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
clearInput,
|
||||
confirmCleared: () => true
|
||||
})
|
||||
// Body must NOT ride out with the clear — the confirm happens in between.
|
||||
expect(writes()).toEqual([clearInput])
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS)
|
||||
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited')])
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited'), NATIVE_CHAT_SUBMIT])
|
||||
})
|
||||
|
||||
it('preserves the body-to-Enter gap when the renderer stalls past both nominal deadlines', async () => {
|
||||
vi.useRealTimers()
|
||||
const writeTimes = new Map<string, number>()
|
||||
sendRuntimePtyInput.mockImplementation((_settings, _pty, bytes: string) => {
|
||||
writeTimes.set(bytes, performance.now())
|
||||
return true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
clearInput: buildAgentTuiClearInputForText(DRAFT),
|
||||
confirmCleared: () => true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'queued')
|
||||
|
||||
const blockedUntil =
|
||||
performance.now() + NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + 50
|
||||
while (performance.now() < blockedUntil) {
|
||||
// Simulate a renderer long task delaying both nominal deadlines.
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(writeTimes.has(NATIVE_CHAT_SUBMIT)).toBe(true), {
|
||||
timeout: NATIVE_CHAT_SUBMIT_DELAY_MS + 1_000
|
||||
})
|
||||
await vi.waitFor(() => expect(writeTimes.has(buildNativeChatPasteBytes('queued'))).toBe(true))
|
||||
expect(
|
||||
writeTimes.get(NATIVE_CHAT_SUBMIT)! - writeTimes.get(buildNativeChatPasteBytes('edited'))!
|
||||
).toBeGreaterThanOrEqual(NATIVE_CHAT_SUBMIT_DELAY_MS - 20)
|
||||
expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan(
|
||||
writes().indexOf(buildNativeChatPasteBytes('queued'))
|
||||
)
|
||||
})
|
||||
|
||||
it('widens to a maximal burst when the draft is still observed on the line', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
clearInput,
|
||||
confirmCleared: () => false
|
||||
})
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS)
|
||||
expect(writes()).toEqual([
|
||||
clearInput,
|
||||
AGENT_TUI_CLEAR_INPUT_MAX,
|
||||
buildNativeChatPasteBytes('edited')
|
||||
])
|
||||
})
|
||||
|
||||
it('re-clears before the body, never after it', () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
|
||||
clearInput: buildAgentTuiClearInputForText(DRAFT),
|
||||
confirmCleared: () => false
|
||||
})
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
const order = writes()
|
||||
expect(order.indexOf(AGENT_TUI_CLEAR_INPUT_MAX)).toBeLessThan(
|
||||
order.indexOf(buildNativeChatPasteBytes('edited'))
|
||||
)
|
||||
})
|
||||
|
||||
it('charges the confirm gap to the handle so the send card outlives the Enter', () => {
|
||||
const withConfirm = sendNativeChatMessage(SETTINGS, PTY, 'a', {
|
||||
clearInput: '\x15',
|
||||
confirmCleared: () => true
|
||||
})
|
||||
expect(withConfirm.settleAfterMs).toBe(
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_CLEAR_CONFIRM_MS
|
||||
)
|
||||
})
|
||||
|
||||
it('submits before a queued send starts after clear confirmation', async () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'first', {
|
||||
clearInput,
|
||||
confirmCleared: () => true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'second')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expect(writes()).toEqual([
|
||||
clearInput,
|
||||
buildNativeChatPasteBytes('first'),
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('second')
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('image sends with a parked multi-line draft', () => {
|
||||
it('clears every draft line before pasting, so no line rides along with the image', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
|
||||
clearInput
|
||||
})
|
||||
expect(writes()[0]).toBe(clearInput)
|
||||
})
|
||||
|
||||
it('clears exactly once — a second Ctrl+U would wipe the just-pasted image', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
|
||||
clearInput
|
||||
})
|
||||
vi.advanceTimersByTime(10_000)
|
||||
expect(writes().filter((write) => write === clearInput)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('submits the image send before a queued message starts', async () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
|
||||
clearInput,
|
||||
confirmCleared: () => true
|
||||
})
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'second')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
NATIVE_CHAT_CLEAR_CONFIRM_MS +
|
||||
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS +
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
)
|
||||
|
||||
expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan(
|
||||
writes().indexOf(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -142,6 +142,23 @@ describe('sendNativeChatMessage', () => {
|
||||
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('does not let a canceled queued send stall the sends behind it', async () => {
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'first')
|
||||
const canceled = sendNativeChatMessage(SETTINGS, PTY, 'canceled')
|
||||
sendNativeChatMessage(SETTINGS, PTY, 'third')
|
||||
canceled.cancel()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
|
||||
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('first'),
|
||||
NATIVE_CHAT_SUBMIT,
|
||||
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
buildNativeChatPasteBytes('third')
|
||||
])
|
||||
})
|
||||
|
||||
it('does not serialize sends across different PTYs', () => {
|
||||
sendNativeChatMessage(SETTINGS, 'pty-a', 'one')
|
||||
sendNativeChatMessage(SETTINGS, 'pty-b', 'two')
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/runtime/runtime-terminal-inspection'
|
||||
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt'
|
||||
import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear'
|
||||
import {
|
||||
NATIVE_CHAT_ADVANCE_BUFFER_MS,
|
||||
NATIVE_CHAT_QUESTION_STEP_MS,
|
||||
@@ -34,20 +35,86 @@ export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
|
||||
// start from an empty line so a prior cancelled paste cannot glue onto the next
|
||||
// prompt. Not used on verified option commands — model-switch confirmation
|
||||
// observes the PTY and Ctrl+U can miss confirmation markers.
|
||||
//
|
||||
// One Ctrl+U only ever clears ONE logical line. When the line may hold an
|
||||
// injected multi-line launch draft, callers pass `clearInput` built by
|
||||
// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured
|
||||
// 2N-1 law and the sequences that do NOT work.
|
||||
export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
|
||||
|
||||
/** Gap before re-reading the agent's input line to confirm a clear landed. */
|
||||
export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140
|
||||
|
||||
export type NativeChatSendOptions = {
|
||||
/** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */
|
||||
clearInput?: string
|
||||
/**
|
||||
* Observed check that the input line is now empty.
|
||||
* Supplied only for launch-draft replacement; when it reports "not cleared"
|
||||
* the send widens to a maximal burst before writing the body rather than
|
||||
* pasting on top of residue.
|
||||
*/
|
||||
confirmCleared?: () => boolean
|
||||
}
|
||||
|
||||
/** Cancels an in-flight send's pending pty writes (the delayed Enter, and any
|
||||
* later question bodies/Enters). Safe to call after the send completes. */
|
||||
export type NativeChatSendHandle = {
|
||||
cancel: () => void
|
||||
/** Time after which every scheduled write has fired and the handle can drop. */
|
||||
settleAfterMs: number
|
||||
/** Actual completion, which can outlive the nominal schedule if the renderer stalls. */
|
||||
settled?: Promise<void>
|
||||
}
|
||||
|
||||
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
|
||||
|
||||
function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): void {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
function clearUnsubmittedAgentInput(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
options?: NativeChatSendOptions
|
||||
): void {
|
||||
sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `writeBody` once the input line is clear. With no `confirmCleared` the
|
||||
* clear is a plain in-order write on the same byte stream, so the TUI consumes
|
||||
* it before the body and the body follows immediately. With one, we pause to
|
||||
* actually look at the agent's input line, and widen to a maximal burst when the
|
||||
* draft is still visible — the injected line count is only a lower bound on what
|
||||
* the buffer holds, since the user can type into the TUI directly.
|
||||
*/
|
||||
function clearThenWrite(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
options: NativeChatSendOptions | undefined,
|
||||
delay: (ms: number, fn: () => void) => void,
|
||||
writeBody: () => void
|
||||
): void {
|
||||
clearUnsubmittedAgentInput(settings, ptyId, options)
|
||||
const confirmCleared = options?.confirmCleared
|
||||
if (!confirmCleared) {
|
||||
writeBody()
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => {
|
||||
let cleared = false
|
||||
try {
|
||||
cleared = confirmCleared()
|
||||
} catch {
|
||||
// An unreadable terminal is unconfirmed; the maximal clear remains safe.
|
||||
}
|
||||
if (!cleared) {
|
||||
sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX)
|
||||
}
|
||||
writeBody()
|
||||
})
|
||||
}
|
||||
|
||||
/** Extra time a send needs when it stops to confirm the clear before the body. */
|
||||
function clearConfirmDurationMs(options?: NativeChatSendOptions): number {
|
||||
return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,27 +128,31 @@ function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): v
|
||||
export function sendNativeChatMessage(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
text: string
|
||||
text: string,
|
||||
options?: NativeChatSendOptions
|
||||
): NativeChatSendHandle {
|
||||
return enqueueNativeChatPtySend(
|
||||
ptyId,
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS,
|
||||
NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options),
|
||||
({ isCancelled, delay, markSubmitted }) => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
clearUnsubmittedAgentInput(settings, ptyId)
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
clearThenWrite(settings, ptyId, options, delay, () => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
// Schedule from the actual body write: an overdue clear-confirm callback
|
||||
// must not collapse the required body-to-Enter gap after a renderer stall.
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
})
|
||||
},
|
||||
{
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId)
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -146,16 +217,17 @@ export function sendNativeChatMessageWithImageAttachments(
|
||||
settings: RuntimeSettings,
|
||||
ptyId: string,
|
||||
text: string,
|
||||
imagePaths: readonly string[]
|
||||
imagePaths: readonly string[],
|
||||
options?: NativeChatSendOptions
|
||||
): NativeChatSendHandle {
|
||||
if (imagePaths.length === 0) {
|
||||
return sendNativeChatMessage(settings, ptyId, text)
|
||||
return sendNativeChatMessage(settings, ptyId, text, options)
|
||||
}
|
||||
const trimmedText = text.trim()
|
||||
const durationMs =
|
||||
trimmedText.length > 0
|
||||
(trimmedText.length > 0
|
||||
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
: NATIVE_CHAT_SUBMIT_DELAY_MS
|
||||
: NATIVE_CHAT_SUBMIT_DELAY_MS) + clearConfirmDurationMs(options)
|
||||
return enqueueNativeChatPtySend(
|
||||
ptyId,
|
||||
durationMs,
|
||||
@@ -163,30 +235,31 @@ export function sendNativeChatMessageWithImageAttachments(
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
clearUnsubmittedAgentInput(settings, ptyId)
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
for (const imagePath of imagePaths) {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
|
||||
}
|
||||
if (trimmedText.length > 0) {
|
||||
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
})
|
||||
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
clearThenWrite(settings, ptyId, options, delay, () => {
|
||||
if (isCancelled()) {
|
||||
return
|
||||
}
|
||||
for (const imagePath of imagePaths) {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
|
||||
}
|
||||
if (trimmedText.length > 0) {
|
||||
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
return
|
||||
}
|
||||
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
|
||||
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
|
||||
markSubmitted()
|
||||
})
|
||||
},
|
||||
{
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId)
|
||||
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user